This is an automated email from the ASF dual-hosted git repository.
jason810496 pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/airflow.git
The following commit(s) were added to refs/heads/main by this push:
new 762495647a9 Add task loop authoring ADRs for the TypeScript, Java and
Go SDKs (#76365)
762495647a9 is described below
commit 762495647a9c5ba23d49267230c69c79087653a2
Author: Jason(Zhe-You) Liu <[email protected]>
AuthorDate: Tue Nov 10 13:24:17 2026 +0800
Add task loop authoring ADRs for the TypeScript, Java and Go SDKs (#76365)
* Add task loop authoring ADRs for the TypeScript, Java and Go SDKs
Each SDK records how an author writes a task loop in its own language, with
the shared semantics left to the core ADR the three of them cite.
* Take the terminal task from the body's leaf, not the gate's wiring
A gate that bound a non-leaf body task was treated as naming the terminal,
and nothing then ordered the real leaf before it. Say the terminal is the
leaf in all three SDKs, and name the gate a fixed-count loop gets.
* Say that a single-value read from outside a loop is rejected
The three ADRs let the consumer's declared type pick the reading, but
which reading is legal depends on where the consumer sits, which no type
can see. Core serves only the all-iterations read from outside the loop,
so say that the SDK rejects the other one when the Dag is read.
* Type the TypeScript loop signature the way dag.if is typed
The gate's inputs were typed as the handler's own argument object, so the
worked example did not type-check: one type had to be both the arguments
and the refs supplying them. Take them through TaskInputs under NoInfer,
as dag.if does, and add the two refs the decisions name.
TaskInputs accepts any TaskRef for any argument, so decision 5 cannot
promise a compile-time check of the reading either. Say what tsc does
check.
* Drop the type parameter from the Go XComSequence
Handlers register as fn any and the SDK binds their parameters by
reflection, which instantiates no generic type, so the runtime could
never build an XComSequence[Metrics] to pass or to assert Previous to.
Name the element type at the read instead, next to UnmarshalPrevious.
* Report NotInLoop from actx.Loop instead of panicking
Logger, Client, TaskInstance and DagRun all hand back a stored field and
never panic; only NewContext panics, and only on a nil argument. Loop
already returns an error, so a handler outside a loop gets one there.
* Give the Java interface surface the reads it needs
getPrevious(Class) had no counterpart on Client, which returns Object and
takes no Class, and a Class cannot carry XComSequence<Metrics>, so a
mapped terminal lost its element type. Add both overloads to Client and
name them. The report example also drew an ordering edge out of the body
and read nothing, so point it at the loop and show the sequence read.
* Name the Java types the loop refs need changed
TaskRef and TaskGroupRef are final Kotlin classes, so neither LoopTaskRef
nor LoopRef compiled as written, and UntilTask did not extend Task the way
ConditionTask and SwitchTask do while taking decide, which is already
ConditionTask's verb. Say both, and give the gate a verb of its own.
* Declare cleanup in the TypeScript and Go examples
Both worked examples drew an ordering edge to a task neither declared.
* Generate the loop overload pair only where both readings exist
A pair per XComSequence parameter is a cross-product: four methods for a
task reading two loop sequences, eight for three. Generate it only where
a loop body task returns the element type, and say what it would cost
otherwise.
* Default UntilTask.execute the way ConditionTask does
A gate class implements check(), so Task.execute has to come from
somewhere; ConditionTask overrides it with a throwing default and the
gate takes the same one. Also line the interface surface's terminal read
up with rule 3, which now covers a surface that wires no data.
* Ask for the outside reading with all()
Decision 6 had the consumer's declared parameter type pick between one
iteration's value and every iteration's. Nothing can check that:
`TaskInputs` takes any `TaskRef` for any argument, and
`serializeArgBindings` records a task id and no type, so a wrong reading
serialized cleanly and failed partway through the run.
`all()` puts the outside reading in the Dag, and a bare body ref outside
the loop is rejected when the Dag is read. Go and Java keep the bare ref,
which their registration and generated overloads can check.
* Make getPrevious the only previous-iteration accessor
Decision 5 cited a split in `client.getXCom` that does not exist: it is
one generic method, `getXCom<T = unknown>()`. ADR-0017 also asks every
runtime for one accessor rather than two, so the `previous` field goes
and `getPrevious<T = unknown>()` stands alone.
* Match the UnmarshalPrevious comment to decision 5
The comment said `XComClient.GetXCom` decodes into its out parameter.
Decision 5 already says that parameter is reserved for the decoding and
ignored today; the comment now says the same.
---
go-sdk/adr/0009-task-loop-authoring-interface.md | 245 +++++++++++++++
java-sdk/adr/0003-task-loop-authoring-interface.md | 327 +++++++++++++++++++++
ts-sdk/adr/0003-task-loop-authoring-interface.md | 214 ++++++++++++++
3 files changed, 786 insertions(+)
diff --git a/go-sdk/adr/0009-task-loop-authoring-interface.md
b/go-sdk/adr/0009-task-loop-authoring-interface.md
new file mode 100644
index 00000000000..6ba0b1e2d78
--- /dev/null
+++ b/go-sdk/adr/0009-task-loop-authoring-interface.md
@@ -0,0 +1,245 @@
+<!--
+ 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.
+ -->
+
+# 9. Task loop authoring interface
+
+Date: 2026-11-03
+
+## Status
+
+Proposed.
+
+## Context
+
+A task loop runs a task group again and again until a gate says stop. What a
loop means, and the
+eight authoring rules every SDK follows, are in
+[`airflow-core/adr/lang-sdk/0017`](../../airflow-core/adr/lang-sdk/0017-task-loops-across-lang-sdks.md).
+This ADR records the Go spelling of them.
+
+## Decision
+
+1. **`refine.Loop(...)` closes a group.** `dag.TaskGroup("refine")` builds and
fills it as usual
+ ([ADR 8](0008-native-dag-interface.md)).
+2. **`maxIterations` is positional and `Until` is an option.** Every loop has
a bound; only some add a
+ callable. `airflow.Until(fn, opts...)` is the one `LoopOption`, sealed as
`TaskOption` is.
+3. **The gate is a task handler wired with `airflow.Inputs`**, which is its
own data, as any task's
+ is. The terminal task is the body's single leaf, which the SDK orders
before the gate. Wiring the
+ leaf in is what gets the decision checked against what the body produces,
since registration
+ already checks a parameter against the recorded output type of whatever
`Inputs` names.
+4. **`actx.Loop()` supplies the loop context**, beside `Logger`, `Client`,
`TaskInstance` and
+ `DagRun`. It returns a value and an error, because reading the previous
iteration is a round trip,
+ and outside a loop that error is `airflow.NotInLoop`.
+5. **`Index`, `MaxIterations` and `Previous` are struct fields**, as
`DagRun`'s are, and
+ `UnmarshalPrevious(out)` binds the raw one. That is the split `GetXCom`
declares, whose `value`
+ parameter is reserved for exactly this decoding and ignored today.
+6. **A body ref is read from outside the loop unchanged.** The consuming
handler declares `T` for one
+ iteration or `airflow.XComSequence` for every iteration, and registration
checks which it asked
+ for against where it sits.
+7. **`*LoopRef` is the `Node` that carries edges.** An order-only edge from a
body ref to a task
+ outside is rejected when it is declared.
+
+## Example
+
+Newton's method for the square root of two, stopping on the error or at ten
iterations.
+
+```go
+type Metrics struct {
+ Estimate float64 `json:"estimate"`
+ Error float64 `json:"error"`
+}
+
+func improve(actx airflow.Context) (float64, error) {
+ loop, err := actx.Loop()
+ if err != nil {
+ return 0, err
+ }
+ actx.Logger().Info("refining", "iteration", loop.Index, "of",
loop.MaxIterations)
+
+ estimate := 1.0
+ if loop.Previous != nil {
+ var previous Metrics
+ if err := loop.UnmarshalPrevious(&previous); err != nil {
+ return 0, err
+ }
+ estimate = previous.Estimate
+ }
+ return (estimate + 2.0/estimate) / 2.0, nil
+}
+
+func evaluate(actx airflow.Context, estimate float64) (Metrics, error) {
+ return Metrics{Estimate: estimate, Error: math.Abs(estimate*estimate -
2)}, nil
+}
+
+func accurateEnough(actx airflow.Context, result Metrics)
(airflow.LoopDecision, error) {
+ return airflow.LoopDecision{
+ Stop: result.Error < 1e-6,
+ Reason: fmt.Sprintf("error %.2g", result.Error),
+ }, nil
+}
+
+func report(actx airflow.Context, history airflow.XComSequence) error {
+ n, err := history.Len()
+ if err != nil {
+ return err
+ }
+ var last Metrics
+ if err := history.UnmarshalAt(n-1, &last); err != nil {
+ return err
+ }
+ actx.Logger().Info("settled", "iterations", n, "error", last.Error)
+ return nil
+}
+
+func cleanup(actx airflow.Context) error {
+ return nil
+}
+```
+
+```go
+dag := airflow.Dag("go_refine", airflow.DagSpec{Schedule: "@daily"})
+refine := dag.TaskGroup("refine")
+
+improved := refine.Task(improve)
+evaluated := refine.Task(evaluate, airflow.Inputs(improved))
+
+refinement := refine.Loop(10, airflow.Until(accurateEnough,
airflow.Inputs(evaluated)))
+
+dag.Task(report, airflow.Inputs(evaluated)) // every iteration's metrics,
through the gate
+refinement.Before(dag.Task(cleanup)) // order only: the whole loop, not
one iteration
+```
+
+`evaluated` is written twice and means two things. Inside the loop it is one
iteration's `Metrics`,
+which is what the gate takes. Outside it is an `airflow.XComSequence`, which
is what
+`report` takes. Decision 6 is what makes registration accept both.
+
+A loop that only counts needs no callable:
+
+```go
+poll := dag.TaskGroup("poll")
+poll.Task(checkOnce)
+poll.Loop(3)
+```
+
+## Signature
+
+```go
+package airflow
+
+// Loop turns the group into a task loop. maxIterations must be positive.
+func (g *TaskGroupRef) Loop(maxIterations int, opts ...LoopOption) *LoopRef
+
+// Until supplies the gate's callable and the gate's own task options,
including the Inputs that
+// name the loop's terminal task. fn takes airflow.Context first and the wired
inputs after, as
+// every handler does, and returns either shape:
+//
+// func(actx airflow.Context, args ...) (LoopDecision, error)
+// func(actx airflow.Context, args ...) (bool, error)
+func Until(fn any, opts ...TaskOption) LoopOption
+
+// LoopOption is sealed: its only method is unexported, so a loop takes
SDK-defined options and
+// nothing else.
+type LoopOption interface{ applyLoop(*loopConfig) error }
+
+// LoopDecision is what an until callable returns. A bool return is a decision
with no reason.
+type LoopDecision struct {
+ Stop bool
+ Reason string
+}
+
+// LoopRef is a Node, so Before and After carry edges for the whole loop.
+type LoopRef struct{ /* unexported */ }
+
+// Loop reads the previous iteration's result once and caches it. Outside a
loop body or its gate it
+// reports NotInLoop, and it reports the read's own error when that fails.
+func (c Context) Loop() (Loop, error)
+
+// NotInLoop is what Loop reports for a handler that is not in a loop. Spelled
like the SDK's other
+// sentinels, sdk.XComNotFound among them: errors.Is(err, airflow.NotInLoop).
+var NotInLoop = errors.New("not in a loop")
+
+// Loop is handed back by value, as DagRun is: the fields are the runtime's
answer, not state for
+// a handler to write.
+type Loop struct {
+ Index int
+ MaxIterations int
+
+ // Previous is the previous iteration's terminal result as it arrived: nil
at Index 1, and an
+ // XComSequence when the terminal task is mapped.
+ Previous any
+}
+
+// UnmarshalPrevious binds Previous to a DTO, the decoding
XComClient.GetXCom's value parameter is
+// reserved for and does not do today. It leaves out untouched when there is
no previous iteration.
+func (l Loop) UnmarshalPrevious(out any) error
+
+// XComSequence is the lazy sequence of ADR-0016: every iteration of a loop
task read from outside
+// the loop, or every instance of a mapped task.
+//
+// It is not generic. The SDK builds it by reflection from a handler
registered as fn any, and Go
+// reflection instantiates no generic type and synthesizes no method set, so
an XComSequence[T] is
+// one the runtime could never hand to a handler. The element type is named at
the read instead.
+type XComSequence interface {
+ Len() (int, error)
+
+ // At returns one element as it arrived, as GetXCom returns a stored value.
+ At(offset int) (any, error)
+
+ // UnmarshalAt decodes one element into a DTO, as UnmarshalPrevious
decodes Previous.
+ UnmarshalAt(offset int, out any) error
+}
+```
+
+## Consequences
+
+- **A fixed-count loop needs no runtime support.** `Loop(3)` emits `has_until:
false`, which core
+ completes without a worker (ADR-0017 decision 4).
+- **The gate is an ordinary task.** `airflow.Until` takes `TaskOption`, so
`airflow.TaskSpec{Queue: ...}`
+ lands on it, and its task_id defaults to the Go function name under the
group's prefix.
+ `TaskSpec{TaskID: "accurate_enough"}` is how it gets a snake_case id, as for
any task. A loop with
+ no `airflow.Until` has no name to take, so its gate is `__loop_gate`, as
Python's is.
+- **The body's single leaf is checked twice**: at registration, for the faster
message, and by the Dag
+ processor, which decides (ADR-0017 decision 3). A nested loop and a loop
inside a mapped task group
+ are rejected the same way.
+- **`actx.Loop()` reports `NotInLoop` outside a loop** rather than panicking.
The other accessors
+ hand back a stored field and never panic; only `NewContext` panics, and only
on a nil argument. A
+ handler moved out of a group still fails where it was moved, through the
error it already checks.
+- **A mapped terminal needs no second accessor.** `Previous` holds an
`airflow.XComSequence` when
+ `terminal_is_mapped` is true and a plain value otherwise, which is what
Python's one
+ `loop.previous` property does. The mapped spelling is a type assertion,
+ `loop.Previous.(airflow.XComSequence)`, since a sequence is not something to
decode into.
+- **The reading has to match where the consumer sits.** Core serves only the
all-iterations read from
+ outside the loop, so a consumer outside it that declares one iteration's
value is rejected when the
+ Dag is read rather than failing partway through the run.
+- **Nothing in Go carries the element type.** `Previous` is a struct field and
`XComSequence` is
+ built by reflection, and neither can be generic, so both name the type at
the read:
+ `UnmarshalPrevious` for one value, `UnmarshalAt` for one element. Go is the
only one of the three
+ SDKs where the signature does not say it.
+
+## Alternatives
+
+- **`*Loop` rather than `Loop`.** Rejected: the fields are the runtime's
answer, and a pointer invites
+ writing to them. `DagRun()` hands back a value for the same reason.
+- **A `Loop` parameter on the handler**, as the Java SDK injects one.
Rejected: a Go handler already
+ takes `airflow.Context` first, and everything else Airflow gives a task
hangs off it.
+- **A generic `XComSequence[T]`**, so the element type sits in the handler's
signature. Rejected
+ because it cannot be built: handlers register as `fn any`, and Go reflection
instantiates no
+ generic type, so the runtime has nothing to pass for such a parameter and
nothing to assert
+ `Previous` to.
+- **Fetching `Previous` before every body handler runs**, which would drop the
error from `Loop()`.
+ Rejected: a handler that never reads it would pay a round trip from
iteration 2 on.
diff --git a/java-sdk/adr/0003-task-loop-authoring-interface.md
b/java-sdk/adr/0003-task-loop-authoring-interface.md
new file mode 100644
index 00000000000..ad788fc34db
--- /dev/null
+++ b/java-sdk/adr/0003-task-loop-authoring-interface.md
@@ -0,0 +1,327 @@
+<!--
+ 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-0003: Task Loop — Java Authoring Interface
+
+## Status
+
+Proposed.
+
+## Context
+
+A task loop runs a task group again and again until a gate says stop. What a
loop means, and the
+eight authoring rules every SDK follows, are in
+[`airflow-core/adr/lang-sdk/0017`](../../airflow-core/adr/lang-sdk/0017-task-loops-across-lang-sdks.md).
+This ADR records the Java spelling of them, on both surfaces
+[ADR-0002](0002-native-dag-interface.md) defines.
+
+## Decision
+
+1. **`@Airflow.Loop` is `@Airflow.TaskGroup` with a bound.** On the interface
surface,
+ `dag.loop(groupId, maxIterations)` stands where `dag.taskGroup(groupId)`
does.
+2. **`@Airflow.Until` marks the gate's method** inside that class. Its absence
is a fixed-count loop.
+3. **The terminal task is the body's single leaf**, which the SDK orders
before the gate. The gate's
+ data parameters are bound in `depends()` like any other task's, so binding
the leaf is what gets
+ the decision checked against what the body produces. The interface surface
binds nothing and reads
+ the terminal off `Loop` (decision 6).
+4. **`Loop` is injected as a method argument**, beside `Client` and `Context`
(ADR-0002), into a body
+ task or the gate. On the interface surface, where `Task.execute(Context,
Client)` is fixed, it is
+ `context.loop()`. The processor injects it only where it is declared, so
nothing else pays for the
+ read.
+5. **`index`, `maxIterations` and `previous` are public final fields**, as
`DagRun.conf` and
+ `DagRun.runId` already are. `previous` is `null` at iteration 1.
+6. **`Loop<T>` is generic over the terminal task's result**, so
`Loop<Metrics>` gives a typed
+ `previous` with nothing to cast. The interface surface has no declaration
to read, so
+ `context.loop()` is a `Loop<Object>` and `getPrevious(Metrics.class)` names
the type at the read.
+ `Client.getXCom` gains that same overload, so naming the type at the read
is one shape across the
+ surface rather than something the loop invents.
+7. **A body task's wiring method returns `LoopTaskRef<T>`.** The processor
generates a wiring
+ parameter of type `XComSequence<E>` as an overloaded pair — one taking
`Arg<XComSequence<E>>`, one
+ taking `LoopTaskRef<E>` — where some loop body task in the Dag returns `E`,
so `javac` checks
+ whichever reading the consumer declares.
+8. **`LoopRef` carries `before` and `after`.** An order-only edge from a body
ref to a task outside is
+ rejected when the Dag is read.
+
+## Example
+
+Newton's method for the square root of two, stopping on the error or at ten
iterations.
+
+```java
[email protected](id = "java_refine", schedule = "@daily")
+public class RefineEstimate {
+ private static final System.Logger log =
System.getLogger(RefineEstimate.class.getName());
+
+ record Metrics(double estimate, double error) {}
+
+ // A loop body: a task group with a bound. "refine.improve",
"refine.evaluate",
+ // "refine.accurate_enough".
+ @Airflow.Loop(id = "refine", maxIterations = 10)
+ static class Refine {
+
+ @Airflow.Task(id = "improve")
+ public double improve(Loop<Metrics> loop) {
+ log.log(INFO, "iteration {0} of {1}", loop.index, loop.maxIterations);
+ double estimate = loop.previous == null ? 1.0 : loop.previous.estimate();
+ return (estimate + 2.0 / estimate) / 2.0;
+ }
+
+ @Airflow.Task(id = "evaluate")
+ public Metrics evaluate(double estimate) {
+ return new Metrics(estimate, Math.abs(estimate * estimate - 2));
+ }
+
+ // The gate. Its parameter is the loop's terminal task, bound in depends().
+ @Airflow.Until(id = "accurate_enough")
+ public LoopDecision accurateEnough(Metrics result) {
+ return new LoopDecision(result.error() < 1e-6, "error
%.2g".formatted(result.error()));
+ }
+ }
+
+ @Airflow.Task(id = "report")
+ public void report(XComSequence<Metrics> history) {
+ log.log(INFO, "settled after {0} iterations", history.length());
+ }
+
+ @Airflow.Task(id = "cleanup")
+ public void cleanup() {}
+
+ @Airflow.Deps
+ static class Wiring implements RefineEstimateDeps {
+ void depends() {
+ var estimate = refine().improve();
+ var metrics = refine().evaluate(estimate);
+ refine().accurateEnough(metrics); // the gate, and the terminal it reads
+ report(metrics); // every iteration's metrics, through
the gate
+ refine().before(cleanup()); // order only: the whole loop, not one
iteration
+ }
+ }
+}
+```
+
+`metrics` is written twice and means two things. Inside the loop it is one
iteration's `Metrics`,
+which is what the gate takes. Outside it is an `XComSequence<Metrics>`, which
is what `report` takes.
+Decision 7 is what makes both compile:
+
+```java
+// Generated by the Airflow Java SDK annotation processor. Do not edit.
+interface RefineEstimateDeps extends Deps {
+ default Refine refine() { return Flow.loop("refine"); }
+
+ interface Refine extends Flow {
+ default LoopTaskRef<Double> improve() { return
Flow.node("refine.improve"); }
+ default LoopTaskRef<Metrics> evaluate(Arg<Double> estimate) { return
Flow.call("refine.evaluate", estimate); }
+ default TaskRef<Void> accurateEnough(Arg<Metrics> result) { return
Flow.call("refine.accurate_enough", result); }
+ }
+
+ default TaskRef<Void> report(Arg<XComSequence<Metrics>> history) { return
Flow.call("report", history); }
+ default TaskRef<Void> report(LoopTaskRef<Metrics> history) { return
Flow.call("report", history); }
+ default TaskRef<Void> cleanup() { return Flow.node("cleanup"); }
+}
+```
+
+A loop that only counts needs no gate method at all:
+
+```java
[email protected](id = "poll", maxIterations = 3)
+static class Poll {
+ @Airflow.Task(id = "check_once")
+ public void checkOnce() {}
+}
+```
+
+### Interface surface
+
+```java
+Dag dag = new Dag("java_refine").config("schedule", "@daily");
+
+LoopRef refine = dag.loop("refine", 10);
+TaskRef improve = refine.task("improve", Improve.class);
+TaskRef evaluate = refine.task("evaluate", Evaluate.class);
+improve.before(evaluate);
+refine.until("accurate_enough", AccurateEnough.class); // terminal: the body's
single leaf
+
+TaskRef report = dag.task("report", Report.class);
+refine.before(report); // the whole loop: this surface wires no data out of
the body
+refine.before(dag.task("cleanup", Cleanup.class));
+```
+
+```java
+public static class Improve implements Task {
+ @Override
+ public void execute(Context context, Client client) {
+ Metrics previous = context.loop().getPrevious(Metrics.class);
+ double estimate = previous == null ? 1.0 : previous.estimate();
+ client.setXCom((estimate + 2.0 / estimate) / 2.0);
+ }
+}
+
+public static class AccurateEnough implements UntilTask {
+ @Override
+ public LoopDecision check(Context context, Client client) {
+ Metrics result = context.loop().getResult(Metrics.class);
+ return new LoopDecision(result.error() < 1e-6, "error
%.2g".formatted(result.error()));
+ }
+}
+
+public static class Report implements Task {
+ @Override
+ public void execute(Context context, Client client) {
+ XComSequence<Metrics> history = client.getXComSequence(Metrics.class,
"refine.evaluate");
+ System.getLogger("report").log(INFO, "settled after {0} iterations",
history.length());
+ }
+}
+```
+
+`UntilTask` extends `Task` and joins `ConditionTask` and `SwitchTask` as a
task kind with its own
+verb — `decide` is already `ConditionTask`'s — so the gate is recognized by
the interface it
+implements rather than by a naming rule.
+
+## Signature
+
+```java
+// Annotation surface. @Airflow.Until carries @Airflow.Task's own attributes
as well.
+@interface Airflow.Loop { String id(); int maxIterations(); }
+@interface Airflow.Until { String id() default ""; }
+
+// The gate's method under @Airflow.Until. Its injected arguments come first
and its wired data
+// after, as any task method's do, and it returns either shape; a boolean is a
decision with no
+// reason.
+//
+// LoopDecision accurateEnough(Metrics result)
+// LoopDecision accurateEnough(Loop<Metrics> loop, Metrics result)
+// boolean accurateEnough(Metrics result)
+
+// Injected into a loop body task or its gate, beside Client and Context. On
the interface
+// surface it is context.loop(). It throws outside a loop.
+// T is the terminal task's result type. Declare it (Loop<Metrics>) and
previous is typed;
+// declare none (Loop<Object>, which context.loop() returns) and name the type
at the read.
+public final class Loop<T> {
+ public final int index;
+
+ public final int maxIterations;
+
+ /** The previous iteration's terminal result, or null at iteration 1. */
+ public final T previous;
+
+ /**
+ * This iteration's terminal result, for a gate on the interface surface,
where there is no
+ * ref to bind it from. Null in a body task. The annotation surface takes it
as a parameter.
+ */
+ public final T result;
+
+ /** previous bound to a DTO, as Client.getXCom's caller names the type it
wants. */
+ public <R> R getPrevious(Class<R> type);
+
+ public <R> R getResult(Class<R> type);
+}
+
+// Added to Client. getXCom returns Object today, so naming the type at the
read is new; these are
+// the overloads getPrevious and getResult are modelled on. Both take the rest
of getXCom's
+// parameters, which keep their defaults.
+public <T> T getXCom(Class<T> type, String taskId);
+
+// A Class cannot carry XComSequence<Metrics>, so the sequence read names the
element type instead.
+public <E> XComSequence<E> getXComSequence(Class<E> elementType, String
taskId);
+
+/** The lazy sequence of ADR-0016, reached as a declared parameter type or as
Loop's T. */
+public interface XComSequence<E> {
+ int length();
+
+ E get(int offset);
+}
+
+/** What an until method returns. A boolean return is a decision with no
reason. */
+public record LoopDecision(boolean stop, String reason) {}
+
+/** Interface surface, beside ConditionTask and SwitchTask, which both extend
Task as well. */
+public interface UntilTask extends Task {
+ LoopDecision check(Context context, Client client);
+
+ /** Never called: the SDK runs a gate through check(), as it runs a
condition through decide(). */
+ @Override
+ default void execute(Context context, Client client) {
+ throw new IllegalStateException("...");
+ }
+}
+
+/** A loop is a group with a bound, so its ref is a TaskGroupRef that also
declares the gate. */
+public class LoopRef extends TaskGroupRef {
+ /** Declares the gate. The terminal task is the body's single leaf. */
+ public TaskRef<Void> until(String taskId, Class<? extends UntilTask> gate);
+}
+
+public class Dag {
+ public LoopRef loop(String groupId, int maxIterations);
+}
+
+/** Wiring view, generated. A loop body task's ref reads as one iteration or
as all of them. */
+public class LoopTaskRef<T> extends TaskRef<T> {}
+```
+
+## Consequences
+
+- **A fixed-count loop needs no runtime support.** A `@Airflow.Loop` with no
`@Airflow.Until` emits
+ `has_until: false`, which core completes without a worker (ADR-0017 decision
4).
+- **Neither surface names the terminal.** Both take the body's single leaf and
serialize the same
+ `terminal_task_id`. The annotation surface binds it into the gate as well,
which is what buys the
+ compile-time check; the interface surface has no wiring to bind it with.
+- **A fixed-count loop's gate is `__loop_gate`.** `@Airflow.Until` carries the
gate's id, and a loop
+ with no gate method has none to carry, so the id falls back under the
group's prefix as Python's
+ does.
+- **The body's single leaf is checked twice**: by the SDK when the Dag is
read, for the faster
+ message, and by the Dag processor, which decides (ADR-0017 decision 3). A
nested loop and a loop
+ inside a mapped task group are rejected the same way.
+- **A wrong `Loop<T>` is caught when the Dag is read.** The processor knows
the terminal task's return
+ type, so `Loop<String>` over a `Metrics` terminal is named as an error
rather than failing as a cast
+ at run time. `Loop<Object>` is always accepted.
+- **The overload pair of decision 7 is generated only where both readings
exist**, that is for an
+ `XComSequence<E>` parameter whose `E` some loop body task returns. A
sequence fed only by a mapped
+ task keeps the one `Arg<XComSequence<E>>` method. The narrowing is what
keeps the pair from
+ multiplying: a task reading two loop sequences would otherwise need four
methods and one reading
+ three would need eight, where in practice almost every such parameter has a
single reading.
+- **`context.loop()` throws outside a loop**, so a task class moved out of a
group fails where it was
+ moved.
+- **A mapped terminal needs no second accessor.** `loop.previous` holds
ADR-0016's lazy sequence when
+ `terminal_is_mapped` is true and a plain value otherwise, which is what
Python's one
+ `loop.previous` property does. The mapped spelling is the declaration,
`Loop<XComSequence<Metrics>>`.
+ A `Class` cannot carry that type, so the interface surface reads the
sequence with
+ `client.getXComSequence(Metrics.class, taskId)`.
+- **The interface surface reads the terminal off `Loop`**, which is the second
half of ADR-0017
+ rule 3: `Task.execute(Context, Client)` is fixed, so there is no wiring to
bind the terminal with.
+ `loop.getResult(Metrics.class)` names the type at the read as
`Client.getXCom` does, and core pins
+ the read to the gate's iteration either way.
+- **The interface surface wires no data out of the body**, so ADR-0017
decision 3 has nothing to
+ rewire there: a consumer outside the loop orders itself after the loop and
reads the sequence
+ through `Client`.
+- **Two shipped types open up.** `TaskRef<T>` and `TaskGroupRef` are final
Kotlin classes today, and
+ `LoopTaskRef<T>` and `LoopRef` sit under them, so both become `open`. Their
constructors stay
+ internal, so the SDK is still the only thing that subclasses them, and `Arg`
stays sealed because
+ sealing constrains only its direct subclasses. The generated wiring view of
a loop extends `Flow`
+ rather than `LoopRef`, since an interface cannot extend a class.
+- **The reading has to match where the consumer sits.** Core serves only the
all-iterations read from
+ outside the loop, so a consumer outside it that declares one iteration's
value is rejected when the
+ Dag is read rather than failing partway through the run.
+
+## Alternatives
+
+- **A getter on an SDK base class**, `getLoop()`, rather than an injected
argument. Rejected for the
+ reason [ADR-0002](0002-native-dag-interface.md) rejects it for `Client` and
`Context`: a task method
+ declares what it needs, and the SDK binds it.
+- **Fetching `previous` for every body method**, which would let `Loop` be
injected everywhere.
+ Rejected: a method that never declares `Loop` would pay a round trip from
iteration 2 on.
diff --git a/ts-sdk/adr/0003-task-loop-authoring-interface.md
b/ts-sdk/adr/0003-task-loop-authoring-interface.md
new file mode 100644
index 00000000000..bd6e1abf3b9
--- /dev/null
+++ b/ts-sdk/adr/0003-task-loop-authoring-interface.md
@@ -0,0 +1,214 @@
+<!--
+ 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-0003: Task Loop — TypeScript Authoring Interface
+
+## Status
+
+Proposed.
+
+## Context
+
+A task loop runs a task group again and again until a gate says stop. What a
loop means, and the
+eight authoring rules every SDK follows, are in
+[`airflow-core/adr/lang-sdk/0017`](../../airflow-core/adr/lang-sdk/0017-task-loops-across-lang-sdks.md).
+This ADR records the TypeScript spelling of them.
+
+## Decision
+
+1. **`refine.loop(...)` closes a group.** `dag.taskGroup("refine")` builds and
fills it as usual.
+2. **The gate is a handler wired in one call**: `loop(until, inputs, spec)`,
the shape
+ `dag.if(handler, inputs, spec)` already has
([ADR-0002](0002-native-dag-interface.md)). `inputs` is
+ the gate's own data, as any task's is.
+3. **The terminal task is the body's single leaf**, which the SDK orders
before the gate. Wiring the
+ leaf into `inputs` is what gets the decision checked against what the body
produces.
+4. **`getLoop()` supplies the loop context**, beside `getContext()` and
`getClient()` (ADR-0002
+ decision 6). It is awaited, because reading the previous iteration is a
round trip.
+5. **`index` and `maxIterations` are fields, and `getPrevious<T>()` is the one
accessor** for the
+ previous iteration's terminal result — one, not two, as ADR-0017 asks of
every runtime. It is a
+ generic method naming its own return type, as `client.getXCom<T>()` is, not
a field with a typed
+ view beside it.
+6. **A body task returns a `LoopTaskRef<T>`**, a `TaskRef<T>` that records the
loop it came from.
+ Inside the loop it reads as one iteration's `T`. Outside it, `all()`
returns a
+ `TaskRef<XComSequence<T>>` and a bare ref is rejected when the Dag is read,
since core serves only
+ the all-iterations read from outside and the SDK records no argument type
it could check instead.
+7. **`LoopRef` carries `before` and `after`.** An order-only edge from a body
ref to a task outside is
+ rejected when the Dag is read.
+
+## Example
+
+Newton's method for the square root of two, stopping on the error or at ten
iterations.
+
+```ts
+import { Dag, getLoop, type LoopDecision, type XComSequence } from
"apache-airflow-ts-sdk";
+
+type Metrics = { estimate: number; error: number };
+
+const dag = new Dag("ts_refine", { schedule: "@daily" });
+const refine = dag.taskGroup("refine");
+
+const improve = refine.task(async function improve(): Promise<number> {
+ const loop = await getLoop();
+ console.log(`iteration ${loop.index} of ${loop.maxIterations}`);
+ const estimate = loop.getPrevious<Metrics>()?.estimate ?? 1.0;
+ return (estimate + 2.0 / estimate) / 2.0;
+});
+
+const evaluate = refine.task(async function evaluate({ estimate }: { estimate:
number }): Promise<Metrics> {
+ return { estimate, error: Math.abs(estimate * estimate - 2) };
+});
+
+async function accurateEnough({ result }: { result: Metrics }):
Promise<LoopDecision> {
+ return { stop: result.error < 1e-6, reason: `error
${result.error.toExponential(1)}` };
+}
+
+const report = dag.task(async function report({ history }: { history:
XComSequence<Metrics> }) {
+ console.log(`settled after ${await history.length()} iterations`);
+});
+
+const cleanup = dag.task(async function cleanup() {});
+
+const estimate = improve();
+const evaluated = evaluate({ estimate });
+
+const refinement = refine.loop(accurateEnough, { result: evaluated }, {
maxIterations: 10 });
+
+report({ history: evaluated.all() }); // every iteration's metrics, through
the gate
+refinement.before(cleanup()); // order only: the whole loop, not one iteration
+```
+
+`evaluated` is written twice. Inside the loop it is one iteration's `Metrics`,
which is what the
+gate takes. Outside it only `evaluated.all()` is allowed, an
`XComSequence<Metrics>`, which is what
+`report` takes; a bare `evaluated` out there is rejected when the Dag is read.
+
+A loop that only counts needs no handler:
+
+```ts
+const poll = dag.taskGroup("poll");
+poll.task(checkOnce)();
+poll.loop({ maxIterations: 3 });
+```
+
+## Signature
+
+```ts
+/** Options of a loop, the trailing argument of `loop()`. */
+interface LoopOptions extends TaskSpec {
+ maxIterations: number;
+}
+
+/** What `until` returns. A bare `boolean` is `{ stop }`. */
+interface LoopDecision {
+ stop: boolean;
+ reason?: string;
+}
+
+/**
+ * The gate's handler. `TArgs` is its own arguments, one key each, exactly as
a task handler takes
+ * its own. Nothing of the SDK's is in `TArgs`; the loop context comes from
`getLoop()`.
+ */
+type UntilCallable<TArgs extends object> = (
+ args: TArgs,
+) => LoopDecision | boolean | Promise<LoopDecision | boolean>;
+
+interface TaskGroupRef {
+ loop(options: LoopOptions): LoopRef;
+ /**
+ * `NoInfer` keeps `TArgs` coming from the handler alone, so `inputs` is
checked key by key against
+ * it rather than widening it. `dag.if` takes its inputs the same way.
+ */
+ loop<TArgs extends object>(
+ until: UntilCallable<TArgs>,
+ inputs: TaskInputs<NoInfer<TArgs>>,
+ options: LoopOptions,
+ ): LoopRef;
+}
+
+/** What `loop()` returns: the whole loop as one node, which is what an
ordering edge leaves from. */
+interface LoopRef extends Node {
+ readonly groupId: string;
+ before(...downstream: readonly Node[]): LoopRef;
+ after(...upstream: readonly Node[]): LoopRef;
+}
+
+/**
+ * What a loop body task returns. It is a `TaskRef<T>`, so it goes wherever
one goes; the loop it
+ * records is what lets the SDK tell a body ref from an ordinary one when the
Dag is read.
+ */
+interface LoopTaskRef<T> extends TaskRef<T> {
+ readonly loopId: string;
+
+ /** Every iteration's value, the one reading core serves from outside the
loop. */
+ all(): TaskRef<XComSequence<T>>;
+}
+
+/**
+ * Read inside a loop body or its gate. Throws anywhere else.
+ * Awaited because it reads the previous iteration's result, which it caches
per task run.
+ */
+function getLoop(): Promise<LoopContext>;
+
+interface LoopContext {
+ readonly index: number;
+ readonly maxIterations: number;
+
+ /**
+ * The previous iteration's terminal result, as the type the caller names,
or `null` at iteration
+ * 1. An `XComSequence<T>` when the terminal task is mapped.
+ */
+ getPrevious<T = unknown>(): T | null;
+}
+```
+
+## Consequences
+
+- **A fixed-count loop needs no runtime support.** `loop({ maxIterations })`
emits `has_until: false`,
+ which core completes without a worker (ADR-0017 decision 4).
+- **The gate is an ordinary task.** `LoopOptions` extends `TaskSpec`, so
`queue` and `retries` land on
+ it, and its id defaults to the handler's function name under the group's
prefix. A fixed-count loop
+ has no handler to take a name from, so its gate is `__loop_gate`, as
Python's is.
+- **The body's single leaf is checked twice**: by the SDK when the Dag is
read, for the faster
+ message, and by the Dag processor, which decides (ADR-0017 decision 3). A
nested loop and a loop
+ inside a mapped task group are rejected the same way.
+- **`getLoop()` throws outside a loop**, so a handler moved out of a group
fails where it was moved.
+- **A mapped terminal needs no new call.**
`getPrevious<XComSequence<Metrics>>()` names the sequence
+ type, and `terminal_is_mapped` tells the runtime which to build.
+- **The reading is written, not inferred.** `serializeArgBindings` records a
task id and no argument
+ type, so the SDK cannot tell which reading a consumer declared. `all()` puts
the outside reading in
+ the Dag instead, and `tsc` checks it because the accessor names its own
return type. A bare body
+ ref outside the loop is rejected when the Dag is read rather than failing
partway through the run.
+- **`loop()` runs once per group.** A group already on an edge can still
become one, since `LoopRef`
+ takes its edges over.
+
+## Alternatives
+
+- **The loop context as another key of the gate's argument**,
`accurateEnough({ loop, result })`.
+ Rejected for the reason [ADR-0002](0002-native-dag-interface.md) rejects it
for `ctx` and `client`:
+ a handler's argument is its own data, and merging SDK values into it leaves
the top-level names open
+ to collide with the author's. Python puts `loop` among the context kwargs
because every Python
+ handler already takes them. No TypeScript handler does.
+- **A synchronous `getLoop()`**, with the previous result fetched before every
body handler runs.
+ Rejected: a handler that never reads it would pay a round trip from
iteration 2 on. Python pays
+ nothing for the same reason, since `loop.previous` is a lazy property.
+- **A bare body ref outside the loop, with the consumer's declared parameter
type picking the
+ reading.** Rejected: nothing can check it. `TaskInputs` accepts any
`TaskRef` for any argument, and
+ `serializeArgBindings` records no type, so a consumer that declared one
iteration's value would
+ serialize cleanly and fail partway through the run. The Go SDK keeps the
bare ref because
+ registration records its handlers' parameter types, and the Java SDK because
it generates an
+ overload per reading; TypeScript has neither, so it asks for the reading
instead.