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 dd44cc0ea56 Add an ADR for task loops across language SDKs (#76364)
dd44cc0ea56 is described below

commit dd44cc0ea56ee4ddd20f0c1116c7b34659fb95d3
Author: Jason(Zhe-You) Liu <[email protected]>
AuthorDate: Tue Nov 10 12:20:43 2026 +0800

    Add an ADR for task loops across language SDKs (#76364)
    
    * Add an ADR for task loops across language SDKs
    
    Records what core owns and what a language SDK owns for a task loop, so no
    SDK re-implements iteration creation in its own language.
    
    * Make the terminal task the body's leaf in authoring rule 3
    
    The gate's wired data named the terminal, so a gate that bound another
    body task left nothing ordering the real leaf before it. Python takes the
    terminal from the group's leaves and adds the edge itself; say that, and
    leave the gate's arguments as ordinary task wiring.
    
    * Name the supervisor alone in the who-runs-what table
    
    ADR-0016 settles the split: the coordinator only starts the SDK runtime
    subprocess, and the supervisor answers every request it sends.
    
    * Count the fifth gate identification module
    
    `jobs/scheduler_job_runner.py` compares `LOOP_GATE_OPERATOR` too, so
    decision 2 reaches five modules rather than four.
    
    * Keep a gate with a callback on the worker
    
    A gate can carry an `on_success_callback` from `default_args`, so
    decision 4 completes it in the scheduler only on the rule
    `TaskInstance.is_task_schedulable` already applies to `EmptyOperator`.
    
    * Say that core changes land before an SDK's loop runs
    
    The consequences claimed an SDK could implement this ADR before any
    core change landed. While a gate is found by `LoopGateOperator`, an
    SDK-emitted gate's success creates no next iteration, and a fixed-count
    gate reaches a worker until decision 4 lands. Both now say so, and a
    consequence records what decision 4 changes for a Python gate.
---
 .../lang-sdk/0017-task-loops-across-lang-sdks.md   | 252 +++++++++++++++++++++
 airflow-core/adr/lang-sdk/README.md                |   1 +
 2 files changed, 253 insertions(+)

diff --git a/airflow-core/adr/lang-sdk/0017-task-loops-across-lang-sdks.md 
b/airflow-core/adr/lang-sdk/0017-task-loops-across-lang-sdks.md
new file mode 100644
index 00000000000..18f9af5e1c2
--- /dev/null
+++ b/airflow-core/adr/lang-sdk/0017-task-loops-across-lang-sdks.md
@@ -0,0 +1,252 @@
+<!--
+ 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-0017: Task Loops Across Lang SDKs
+
+## Status
+
+Proposed.
+
+## Terms
+
+| Term | Means |
+|---|---|
+| Iteration | One pass through the loop body. Iterations count from 1. |
+| Body | The tasks of the loop's task group. |
+| Terminal task | The body's single leaf. Its result is that iteration's 
answer. |
+| Gate | The task that runs after the terminal task and decides whether 
another iteration follows. |
+| `until` | The author's callable that the gate runs. A loop without one runs 
a fixed count. |
+| Decision | What `until` returns: stop or continue, with an optional reason. |
+| Region | The row that scopes one iteration, as it scopes one expansion in 
[ADR-0016](0016-mapped-tasks-across-lang-sdks.md). |
+
+## Decision
+
+1. **Iteration creation stays in core for every language.** Regions, instance 
creation, rerun trajectories and the scope of every XCom read are core's. An 
SDK emits a loop group and runs one callable; it never creates an instance and 
never decides which iteration a read belongs to.
+2. **A loop is a task group with four more serialized fields** — 
`max_iterations`, `has_until`, `terminal_task_id` and `gate_task_id`. The gate 
is found by `gate_task_id` and never by an operator class name, which 
[ADR-0007](0007-taskflow-across-language-boundary.md) decision B already 
rejected: a gate emitted by another SDK cannot honestly claim 
`LoopGateOperator`.
+3. **Loop validation and normalization belong to the Dag processor**, so every 
language gets the same errors: one terminal task, no nested loop, no loop 
inside a mapped task group, no `expand` over a body task from outside, and 
every body-to-outside edge rewired to leave from the gate. An SDK may repeat 
any check for earlier feedback; core's answer is the one that counts.
+4. **A fixed-count loop runs no user code.** With `has_until: false` the 
scheduler completes the gate itself, so counting needs no worker in any 
language. A gate carrying a callback, an inlet or an outlet still goes to a 
worker, by the rule `TaskInstance.is_task_schedulable` already applies to 
`EmptyOperator`. Only a Python Dag reaches that case, since no SDK emits task 
callbacks.
+5. **The decision travels on `SucceedTask.loop_decision`**, which the 
supervisor schema already carries. An SDK sends `{stop, reason}`; core 
revalidates it and fails the gate when `stop` is false at `max_iterations`.
+6. **The loop context arrives on `TIRunContext.loop`** — `index`, 
`max_iterations`, `terminal_task_id`, `terminal_is_mapped` and the region 
fields. No SDK counts iterations or resolves a trajectory.
+7. **Loops add no XCom call of their own.** Reading the previous iteration is 
the existing call with `previous_iteration: true`; reading every iteration is 
ADR-0016 decision 8's lazy sequence, ordered iteration first, then map index.
+8. **The reason is masked in the supervisor**, once for every language. Python 
redacts it in the task
+   process today, but the supervisor already holds whatever a task added 
through `MaskSecret`, so
+   moving the redaction there leaves each SDK one existing call rather than a 
masker of its own.
+
+## Context
+
+Task loops land in Python with the authoring API, the serialization, the 
scheduler and the worker
+each holding a share. An SDK that copied that split would re-implement the 
scheduler's half in its
+own language and the two would drift, which is the same trap 
[ADR-0016](0016-mapped-tasks-across-lang-sdks.md)
+avoids for mapped tasks.
+
+Regions already carry the split. One expansion is scoped to its enclosing 
iteration at no extra
+cost, so an SDK that implements both ADRs gets mapping inside a loop for free.
+
+This ADR fixes what each side owns. What the construct looks like in an 
author's hands is each
+SDK's own, and is recorded in that SDK's own ADR directory.
+
+## Who runs what
+
+| Process | Language | Its share of a loop |
+|---|---|---|
+| Dag processor | Python | Validates the loop and rewires the edges out of the 
body. |
+| Scheduler | Python | Creates iteration 1, and completes a fixed-count gate 
with no worker. |
+| API server, Execution API | Python | Sends `TIRunContext.loop`; on the 
gate's success, validates the decision and creates the next iteration in the 
same transaction. |
+| Supervisor | Python, in the worker process | Masks the reason; forwards 
every XCom request with the caller's iteration already pinned by its token. |
+| SDK runtime | the SDK's own language | Runs the body handlers and the 
`until` callable, and returns the decision. |
+
+## Authoring interface
+
+What an author writes differs per language. These rules do not, so each SDK 
ADR records only its own
+spelling.
+
+1. **A loop is a task group with a bound.** No SDK adds a second kind of group.
+2. **The gate is an ordinary task**, declared with the group and carrying the 
same task options as any
+   other.
+3. **The terminal task is the body's single leaf**, never something the gate 
names. The SDK orders the
+   leaf before the gate, so a gate that binds another task still runs after 
the terminal. The gate's
+   own data arguments are ordinary task wiring, so wiring the terminal into 
them is what gets the
+   decision checked against what the body produces. A surface with no data 
wiring reads the terminal
+   off the loop context instead, in the shape that SDK's `getXCom` has.
+4. **The loop context arrives the way `Client` and `Context` already do** in 
that SDK. It is never
+   mixed into a handler's data arguments.
+5. **`previous` is one accessor**, in the shape that SDK's `getXCom` has. A 
mapped terminal makes it
+   ADR-0016's sequence instead of a plain value.
+6. **A body task's reference is read from outside the loop unchanged.** The 
consumer's declared type
+   picks one iteration's value or every iteration's.
+7. **Ordering edges leave from the loop, not from a body task.** A data edge 
out of a body task is kept
+   and leaves from the gate.
+8. **A plain boolean return is a decision with no reason.**
+
+Rule 4 is where the SDKs look least alike and agree most. Python passes `loop` 
among the context
+kwargs every handler already takes; a TypeScript handler takes only its own 
data, so the context is a
+getter; a Go handler takes `airflow.Context` first, so it hangs off that; a 
Java method takes what it
+declares, so it is injected. Each follows its own SDK, and none invents a rule 
for loops.
+
+## What an SDK emits
+
+A loop group is an ordinary serialized task group with one more key. The gate 
is an ordinary task
+of that group.
+
+```jsonc
+"refine": {
+  "_group_id": "refine",
+  "children": {
+    "refine.improve": ["task", "refine.improve"],
+    "refine.evaluate": ["task", "refine.evaluate"],
+    "refine.accurate_enough": ["task", "refine.accurate_enough"]
+  },
+  "loop": {
+    "max_iterations": 10,
+    "has_until": true,
+    "terminal_task_id": "refine.evaluate",
+    "gate_task_id": "refine.accurate_enough"
+  }
+}
+```
+
+`has_until` is the only thing that says whether a worker runs the gate. The 
callable itself is never
+serialized: the gate re-enters the SDK's own bundle to find it, the way a 
`map()` callable is found
+by ADR-0016 decision 6.
+
+## What the wire carries
+
+Every message a loop needs is already in Execution API `2026-10-30` and the 
supervisor schema that
+mirrors it. An SDK implements this ADR against the protocol as it stands.
+
+| Where | What | Means |
+|---|---|---|
+| `TIRunContext.loop` | `node_id`, `index`, `max_iterations`, 
`terminal_task_id`, `terminal_is_mapped`, `region_id`, `trajectory`, 
`forked_from_region_id`, `resumes_from_index` | The iteration this task runs 
in. Absent when the task is not in a loop. |
+| `SucceedTask.loop_decision`, `TaskState.loop_decision` | 
`LoopDecisionPayload {stop, reason}` | What the gate decided. `reason` is at 
most 512 bytes of UTF-8 and holds no NUL; the payload model rejects the rest. |
+| `GetXCom`, `GetXComCount`, `GetXComSequenceItem`, `GetXComSequenceSlice` | 
`previous_iteration: bool` | Reads the iteration before this one instead of 
this one. |
+| `MaskSecret` | `value`, `name` | Already how a task process tells the 
supervisor what to redact. |
+| Serialized Dag, `task_group.loop` | `max_iterations`, `has_until`, 
`terminal_task_id`, `gate_task_id` | Declared in `serialization/schema.json`, 
so an SDK's Dag validates with no schema change. |
+
+### What changes
+
+No field is added or removed, so **no new Execution API version is needed**. 
The decisions above are
+core-internal, and two of them subtract from the wire rather than add to it.
+
+| Decision | Change |
+|---|---|
+| 2 | Gate identification stops reading `task_instance.operator == 
"LoopGateOperator"` and resolves `gate_task_id` off the serialized group 
instead. Five modules: the two task-instance route modules, 
`jobs/scheduler_job_runner.py`, `serialization/definitions/dag.py` and 
`models/taskinstance.py`. |
+| 4 | A gate with `has_until: false` and no callback, inlet or outlet 
completes in the scheduler, so it sends no `ti_run` and no `SucceedTask` at 
all. |
+| 8 | `redact(reason)` moves out of the task process 
(`execution_time/loop.py`) into the supervisor, beside the redaction it already 
does for logs. |
+
+## One loop, end to end
+
+Three iterations of `refine` — `improve` and `evaluate` in the body, 
`accurate_enough` as the gate,
+`report` waiting outside — with `max_iterations: 10`. L is the loop region.
+
+```
+ SCHEDULER                 WORKER                                   API SERVER
+ (Python)                  supervisor + SDK runtime                 (Execution 
API)
+ ─────────                 ────────────────────────                 
───────────────
+ create_dagrun
+ region L, iteration 1
+ improve/evaluate/gate
+ created at (L, 1)
+    │
+    ├─ improve (L,1) ─────▶ ti_run ───────────────────────────────▶ 
TIRunContext.loop
+    │                       { index: 1, max_iterations: 10,          no 
previous at index 1
+    │                         terminal_task_id, terminal_is_mapped }
+    │                       handler runs
+    │                       SucceedTask ─────────────────────────▶ 
return_value XCom
+    │
+    ├─ evaluate (L,1) ────▶ the terminal task ────────────────────▶ 
return_value XCom
+    │
+    └─ gate (L,1) ────────▶ binding: the terminal result of (L,1)
+                            until(result) → { stop: false, reason }
+                            supervisor redacts reason
+                            SucceedTask{loop_decision} ──────────▶ lock DagRun 
+ gate
+                                                                   
validate_loop_decision
+                                                                   store the 
reason XCom
+ ┌──────────────────────────────────────────────────────────────── create the 
body at (L, 2)
+ ▼                                                                 in the same 
transaction
+ iteration 2 is scheduled
+ like any other task
+    │
+    ├─ improve (L,2) ─────▶ GetXCom{ previous_iteration: true } ──▶ (L,1)'s 
terminal result
+    │                       handler runs, SucceedTask
+    │
+    ├─ evaluate (L,2) ────▶ … ────────────────────────────────────▶ 
return_value XCom
+    │
+    └─ gate (L,2) ────────▶ until(result) → { stop: true }
+                            SucceedTask{loop_decision} ──────────▶ validate, 
create nothing
+ the gate succeeded, so
+ what follows the loop
+ becomes schedulable
+    │
+    └─ report ────────────▶ binding: sequence over "refine.evaluate"
+                            GetXComCount / GetXComSequenceItem ──▶ every 
iteration, in order
+```
+
+Three things the picture fixes:
+
+- **The gate is the loop's only exit.** `report` is downstream of the gate, 
not of `evaluate`, because
+  decision 3 rewired that edge at parse time.
+- **The next iteration is created by the API server**, inside the transaction 
that accepts the
+  decision, not by the SDK and not by a second call.
+- **A fixed-count loop skips the whole middle column.** With `has_until: 
false` the scheduler
+  completes each gate itself (decision 4), so no worker is involved in 
deciding anything.
+
+## What an SDK runtime does
+
+Nothing here is language-specific.
+
+1. Read `ti_context.loop` from `StartupDetails`. Its absence means the task is 
not in a loop.
+2. Expose `index`, `max_iterations` and the previous iteration's terminal 
result to a body handler.
+   `previous` is the existing XCom call with `previous_iteration: true`, and 
is absent at `index` 1.
+   It is one accessor, not two: a mapped terminal makes it ADR-0016's sequence 
rather than a plain
+   value, which is what Python's single `loop.previous` property does.
+3. On the gate, read this iteration's terminal result — one value, or 
ADR-0016's sequence when
+   `terminal_is_mapped` is true — and run `until`.
+4. Send the result on `SucceedTask.loop_decision`. A `stop` of false at 
`max_iterations` is core's to
+   reject, so the SDK sends what the callable returned. A secret the reason 
might carry is registered
+   with `MaskSecret` as it would be for a log line; the SDK does not redact.
+5. A callable that throws fails the gate like any other task body, and the 
loop stops there.
+
+## Consequences
+
+- **A loop costs an SDK one context object, one wire field and one callable.** 
Everything else is
+  reuse: the group it is built on, the XCom calls, and the sequence ADR-0016 
already defines.
+- **A fixed-count loop is available before an SDK implements anything, once 
decision 4 lands.** It
+  leaves no code to run, so `has_until: false` then works as soon as the SDK 
can emit the group.
+  Until it lands, such a gate is still sent to a worker.
+- **A gate's decision is advice, not authority.** Core revalidates, so a buggy 
or malicious SDK
+  cannot run past `max_iterations`.
+- **Edges out of the body are rewired, not rejected.** Decision 3 keeps 
Python's behaviour, so a Dag
+  that an SDK emits and a Dag that Python emits serialize to the same graph. 
An SDK that would rather
+  reject the edge at its own call site may, and both end up at the same 
serialized Dag.
+- **Python keeps its own authoring checks.** Decision 3 moves where the error 
is authoritative, not
+  where an author first sees it; the Task SDK's parse-time checks stay as the 
faster feedback.
+- **Mapping inside a loop needs no further decision.** The terminal task may 
be mapped, and
+  `terminal_is_mapped` is the only thing a runtime reads to know it.
+- **No new Execution API version, but core goes first.** Every field a loop 
needs shipped with
+  `2026-10-30`, so nothing here waits on a protocol change. Decision 2 still 
has to land before any
+  SDK's loop runs at all: while a gate is found by `LoopGateOperator`, an 
SDK-emitted gate's success
+  creates no next iteration. Decision 4 has to land before a fixed-count loop 
skips the worker. The
+  rest are independent and can land in any order.
+- **A Python fixed-count gate stops behaving like a task.** Decision 4 
completes it in the scheduler,
+  so it takes no pool slot, ignores its `queue` and never retries. A gate with 
`until`, or one
+  carrying a callback, an inlet or an outlet, is unaffected.
+- **Python's reason is redacted one process later.** Decision 8 moves `redact` 
from the task process
+  to the supervisor, which is a behaviour change for Python tasks even though 
no field moves: a task
+  that masks a value and then puts it in a reason is covered by the 
`MaskSecret` it already sends, but
+  one that redacts by hand before returning is unaffected either way.
diff --git a/airflow-core/adr/lang-sdk/README.md 
b/airflow-core/adr/lang-sdk/README.md
index 3c7317d3199..0ff27a58cae 100644
--- a/airflow-core/adr/lang-sdk/README.md
+++ b/airflow-core/adr/lang-sdk/README.md
@@ -42,6 +42,7 @@ bind core interfaces and apply to every language SDK, not 
just the Java SDK.
 - [ADR-0014](0014-bundle-metadata-and-cache-digest.md): bundle metadata — 
dropping the Dag inventory, adding a cache digest.
 - [ADR-0015](0015-per-dag-source-in-bundle-artifact.md): per-Dag source in the 
bundle artifact — de-duplicated by path, with an entrypoint fallback.
 - [ADR-0016](0016-mapped-tasks-across-lang-sdks.md): mapped tasks across Lang 
SDKs — expansion stays in the scheduler, SDKs emit an expand input and consume 
one element.
+- [ADR-0017](0017-task-loops-across-lang-sdks.md): task loops across Lang SDKs 
— iteration creation stays in core, SDKs emit a loop group and run the gate's 
callable.
 
 Decisions specific to a single SDK stay next to that SDK — for example, the Go 
SDK's bundle-format
 decisions live in [`go-sdk/adr/`](../../../go-sdk/adr). Java-SDK-only 
interface-design decisions —

Reply via email to