This is an automated email from the ASF dual-hosted git repository.
potiuk 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 ff04f3bfb3b Add ADR for TaskFlow across the language boundary (#71483)
ff04f3bfb3b is described below
commit ff04f3bfb3b7fcce4d5c7726c798a79ec294b9cf
Author: Jason(Zhe-You) Liu <[email protected]>
AuthorDate: Wed Aug 12 18:25:01 2026 +0800
Add ADR for TaskFlow across the language boundary (#71483)
* Add ADR for TaskFlow across the language boundary
The design settled in #69757 -- where the arg-binding spec is materialized,
why types travel as JSON Schema rather than an Airflow-specific enum, why
the
spec is delivered at ti_run, and which TaskFlow forms deliberately do not
cross
the boundary -- was argued out in the review thread and recorded nowhere a
future reader would find it. Without it the same questions get re-litigated
on
the follow-up PRs that build on the contract.
* Correct ADR-0007 claims about the arg-binding extension contract
Review of the ADR caught that it overstated how far the is_stub flag
generalizes: materialization also requires a DecoratedOperator, because the
builder works from a python_callable signature. It likewise implied the
signature and literal checks run for every stub, when they engage only once
a
call passes an argument, and that _arg_bindings decodes as strictly as
is_stub.
An ADR that misdescribes its own extension point is worse than no ADR, since
the next foreign-runtime operator would be designed against it.
* Drop a false union-annotation gap from ADR-0007
The appendix claimed union annotations were unhandled, carried over from a
mid-review comment that predated the move from a data_type enum to JSON
Schema.
Unions are handled, and thoroughly: test_stub_arg_bindings.py covers dict |
bool,
PEP 604 and legacy optionals, three-way unions, temporals inside unions, and
member dedup. They now serve as evidence for the JSON Schema decision they
were
wrongly listed as a gap in.
Also drop a Consequences bullet that restated the motivation instead of a
consequence.
---
.../0007-taskflow-across-language-boundary.md | 331 +++++++++++++++++++++
airflow-core/adr/lang-sdk/README.md | 1 +
2 files changed, 332 insertions(+)
diff --git
a/airflow-core/adr/lang-sdk/0007-taskflow-across-language-boundary.md
b/airflow-core/adr/lang-sdk/0007-taskflow-across-language-boundary.md
new file mode 100644
index 00000000000..e2f8abf8f8b
--- /dev/null
+++ b/airflow-core/adr/lang-sdk/0007-taskflow-across-language-boundary.md
@@ -0,0 +1,331 @@
+<!--
+ 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-0007: TaskFlow Across the Language Boundary
+
+## Status
+
+Accepted
+
+> **Note:** The inbound half of this decision — the argument-binding spec — is
implemented in
+> [apache/airflow#69757](https://github.com/apache/airflow/pull/69757) (Python
and server side).
+> The Go SDK runtime that consumes the spec lands in
+> [#70209](https://github.com/apache/airflow/pull/70209), and per-map-index
bindings for mapped
+> stubs in [#70570](https://github.com/apache/airflow/pull/70570). Decision G
is forward-looking
+> and not yet implemented; it is recorded here because the wire contract was
designed to admit it.
+
+## Context
+
+TaskFlow is the ergonomic core of authoring in Airflow: call a decorated
function, get an
+`XComArg` back, hand it to another task, and the dependency edge is implied by
the call itself.
+
+Per [AIP-108](https://cwiki.apache.org/confluence/x/pY4mGQ), a task
implemented in another
+language is declared as a `@task.stub` operator inside an ordinary Python Dag
file
+([ADR-0001](0001-java-sdk-airflow-integration.md)), with the body living in a
Lang-SDK build
+artifact shipped in the same bundle
([ADR-0006](0006-no-lang-sdk-source-display.md)). The
+question this ADR answers is what TaskFlow means for such a task.
+
+**The outbound direction already worked.** A Go task function returning `(T,
error)` has its
+first return value pushed as the task's `return_value` XCom
+(`go-sdk/bundle/bundlev1/task.go`, using `api.XComReturnValueKey`), which is
exactly what a
+Python `@task` does. A Lang-SDK task was therefore already consumable by
anything downstream.
+
+**The inbound direction did not.** Stub tasks could only be declared argless:
a TaskFlow call's
+arguments were silently discarded. A Go task needing an upstream's output had
to hand-write a
+`GetXCom` call with the upstream's `task_id` hardcoded in Go — restating in
the foreign language
+the wiring the Dag file had already expressed, and duplicating it at every
call site.
+
+The root cause is an asymmetry in how call arguments are recovered at
execution time. A Python
+worker gets them for free: it re-parses the Dag file and deserializes the
operator, which carries
+its own `op_args`/`op_kwargs`. A foreign runtime cannot parse Python and never
materializes the
+operator, so anything it needs must be **materialized on the Airflow side and
shipped over the
+wire**.
+
+The goal is that the natural TaskFlow call works across the language boundary:
+
+```python
[email protected](queue="golang")
+def transform(country: str, extracted: dict): ...
+
+
+with DAG(...):
+ transform("uk", extract()) # extract() is a normal Python @task
+```
+
+```go
+// The runtime binds "uk" onto country and pulls extract's XCom into extracted.
+func transform(ctx sdk.TIRunContext, log *slog.Logger, country string,
extracted map[string]any) error
+```
+
+## Decision
+
+TaskFlow across the language boundary is defined in two halves. **Outbound**
is unchanged: the
+task's return value becomes its `return_value` XCom. **Inbound** becomes a
materialized
+argument-binding spec, captured when the Dag is serialized, carried in the
serialized Dag, and
+delivered to the runtime at task startup.
+
+### A. Inbound: an ordered, materialized argument-binding spec
+
+A TaskFlow call that passes at least one argument is captured as an ordered
list with one entry
+per declared parameter. (A call passing none captures no spec at all, which is
what keeps
+pre-TaskFlow stub Dags serializing; see decision F.) Every entry carries the
parameter's `name`
+and a `kind`-discriminated payload:
+
+- `XComArgBinding` (`kind: "xcom"`) — the value comes from an upstream task's
`return_value`
+ XCom, identified by `task_id`.
+- `LiteralArgBinding` (`kind: "literal"`) — the value is an inline JSON
literal from the Dag file.
+
+Two properties make this a stable target for a foreign runtime:
+
+- **The wire form is always positional.** Keyword arguments are normalized to
declaration order
+ through signature binding, so a runtime never has to reason about how the
author chose to spell
+ the call. Because `name` is present too, a runtime may bind by position or
by name, whichever
+ suits its language.
+- **Defaults are explicit.** A parameter left unpassed is captured with its
default value and
+ `from_default: true`, so keyword-style consumers can distinguish "the author
passed this" from
+ "this is the signature's default" and leave the latter unclaimed.
+
+Materializing is what makes the contract work at all: the spec is the *only*
thing the runtime
+receives, so it must be complete and self-describing rather than a reference
the runtime is
+expected to resolve on its own.
+
+### B. Materialization belongs in core Dag serialization, behind a generic
`is_stub` flag
+
+The spec is built in Airflow core, from
`OperatorSerialization._serialize_node`, for a non-mapped
+`DecoratedOperator` flagged `is_stub`. The `@task.stub` provider contributes
exactly one line —
+`is_stub: bool = True` — and no knowledge of the binding format.
+
+The `DecoratedOperator` requirement is not incidental: the builder works from
the operator's
+`python_callable` signature and its bound `op_args`/`op_kwargs`, which only a
decorated operator
+has. A foreign-runtime operator that is not TaskFlow-shaped would carry the
`is_stub` marker but
+no bindings, and giving it arguments would need a different capture path.
+
+An earlier revision built the spec inside the standard provider's stub
decorator, and the
+Execution API recognized stub tasks by matching the operator's class name.
Both were rejected in
+review:
+
+1. **The spec is defined against the Execution API's `TaskArgBinding`
schema.** Building it in a
+ provider puts the producer and the schema in different distributions with
different release
+ cadences, guaranteeing drift.
+2. **Deriving JSON Schema from Python type hints is Execution-API-coupled
work.** It has no
+ business in a provider that otherwise contributes operators.
+3. **Gating on the operator's name does not generalize.** Every future
foreign-runtime operator
+ would have to duplicate a magic string to be recognized. A serialized
boolean flag —
+ mirroring how `EmptyOperator` is recognized by `is_empty` rather than by
its class name — lets
+ any such operator opt in by declaring one attribute, and TaskFlow-shaped
ones get argument
+ binding along with it.
+
+The flag is propagated onto mapped operators as well, so "is this task
stub-backed?" is
+answerable regardless of mapping, even though arg-binding materialization
itself skips mapped
+operators.
+
+### C. Outbound: unchanged, and the reason only `return_value` matters
+
+A Lang-SDK task's return value is its `return_value` XCom, as it already was.
This is not merely
+status quo — it is why `return_value` is the only XCom key that carries
meaning across the
+boundary, which is what makes the scope limit in decision H coherent rather
than arbitrary.
+
+### D. The type system is JSON Schema, not a bespoke enum
+
+A foreign runtime must decode a JSON payload into a native typed value, so it
needs to know what
+shape to expect. Each binding therefore carries `value_schema`, a JSON-schema
fragment derived
+from the stub parameter's annotation.
+
+An earlier revision shipped a small Airflow-specific `data_type` enum. Review
pushed for reusing
+JSON Schema instead, and that is what was adopted:
+
+- Every target language already has JSON-schema vocabulary and tooling; an
Airflow-specific enum
+ would have to be re-implemented and kept in sync in every SDK.
+- JSON Schema is open-vocabulary, so the fragment can be carried verbatim and
extended later
+ without a wire-format change. Runtimes ignore keywords they do not
understand.
+- It composes. Nested objects, arrays, formats, and unions all fall out of the
same mechanism
+ rather than needing enum members. Union annotations are the clearest case: a
parameter typed
+ `dict | bool` schemas as an `anyOf` for free, where a closed enum would have
needed a new
+ member (or a rejection) for every combination.
+
+An absent `value_schema` means "unconstrained" — the annotation was missing,
was `Any`, or was
+something a schema could not be generated for — and the runtime falls back to
a decode-only
+check. Absence, not a null value, is the signal.
+
+### E. `value_schema` rides on XCom bindings too
+
+Carrying a schema on a *literal* is uncontroversial. Carrying one on an XCom
binding drew the
+obvious objection: the type of an upstream's payload is not knowable when the
Dag is parsed. A
+Python task is free to return a dict on one branch and `False` on another.
+
+That objection is correct about XCom payloads and does not apply, because the
schema describes
+**the stub's declared parameter** — the contract the foreign runtime binds
*into* — not a
+prediction about what the upstream will actually push. With that reading,
keeping it is
+worthwhile:
+
+1. **The server-side spec is the source of truth for decoding.** The
alternative is to treat each
+ runtime's own annotations as authoritative, which makes decoding behavior a
property of the
+ SDK rather than of the Dag.
+2. **It enables early failure.** Once
[AIP-85](https://cwiki.apache.org/confluence/x/_Q7OEg)
+ lands, a type mismatch in a mixed-language Dag can be reported at
Dag-processing time instead
+ of when the foreign runtime fails to decode.
+3. **It generalizes to native Lang-SDK Dags**, where the same field lets
upstream and downstream
+ types be checked against each other (decision G).
+
+### F. Delivery at `ti_run`, not at parse time alone
+
+The spec reaches the runtime as an optional `arg_bindings` field on the
`ti_run` response's
+`TIRunContext`, read from the serialized Dag through the shared, cached
`DBDagBag` and returned
+only for tasks flagged `is_stub`. A regular task's response is unchanged.
+
+Delivering it purely as part of the serialized Dag was considered and does not
suffice:
+
+- **Per-map-index resolution needs the task instance.** Which slice of an
upstream's output a
+ given mapped stub receives is only knowable once the TaskInstance is joined
in — a parse-time
+ artifact cannot express it.
+- **`ti_run` is where API version negotiation happens.** That is the only
place the field can be
+ withheld from clients that predate it.
+
+The field ships behind a new Execution API version, `2026-10-30` (targeting
Airflow 3.4), with a
+mirrored version in the supervisor wire schema so runtimes pinned to the
previous schema are
+unaffected. Stub Dags that predate arg bindings keep running against older
clients.
+
+`arg_bindings` is `None` for a regular Python task and for a stub whose call
passed no arguments.
+A Lang SDK can short-circuit its whole binding path on that one check.
+
+### G. The same spec is the substrate for native Lang-SDK Dags
+
+*Forward-looking; not implemented.* When Dags can be authored natively in
another language
+([ADR-0003](0003-pure-java-dags.md), [ADR-0004](0004-dag-parsing.md)), such a
Dag declares its
+own tasks, edges, and call arguments. Those arguments are expected to be
expressed as this same
+binding spec rather than a second, native-only format.
+
+The consequence for runtimes is the point: one binding implementation serves
both authoring
+modes. It is also why the spec is deliberately not `@task.stub`-shaped — no
field in
+`TaskArgBinding` refers to stubs, Python, or the decorator, so the same wire
form describes a
+natively authored call. (Its docstrings still say "stub", reflecting the only
producer that
+exists today; that is wording to revisit, not a constraint in the format.)
+
+### H. Scope: what does not cross the boundary
+
+The following raise when the Dag is serialized, rather than being silently
dropped or deferred to
+a runtime failure. Most messages name the working alternative; the ones that
have none say so.
+
+These checks run only once a TaskFlow call actually passes an argument, since
that is when the
+binding contract engages. An argless stub whose *signature* would violate one
of them (a
+`**kwargs` parameter, say) still serializes, exactly as it did before arg
bindings existed.
+
+| Rejected | Why |
+| --- | --- |
+| An XCom key other than `return_value` | Only the return value is meaningful
across the boundary (decision C). Reviewers correctly noted that keys are *not*
always `return_value` — a `multiple_outputs` task's output subscripts to
per-key XComs — so this is a real, deliberate scope limit, not an assumption.
The Java SDK dropped custom-key support before its beta for the same reason. |
+| `.map()` / `.zip()` / `.concat()` results | These are lazily evaluated
Python transformations with no wire representation. |
+| A mapped upstream's aggregated output | A foreign runtime pulls single XCom
rows; a combined output across map indices is not one row. |
+| An upstream output nested inside a list or dict literal | The runtime binds
whole arguments. The fix is to pass the upstream output as its own argument. |
+| `*args` / `**kwargs` | A foreign runtime binds against a fixed parameter
list. |
+| Parameter names that collide with Airflow context keys | Stub signatures
declare data parameters only; the runtime injects its own task context natively
(e.g. the Go SDK's `sdk.TIRunContext` parameter). |
+| Non-JSON-serializable literals, including `NaN` and `Infinity` | The spec
travels as JSON. |
+| A stub task with arguments inside a mapped task group | Such a task has
per-map-index instances but no expand input of its own, so its argument values
are unresolvable both at parse time and server-side. |
+
+Deferred rather than rejected: mapped (`.expand()`) stubs capture no spec and
keep today's
+ignored-argument behavior until
+[#70570](https://github.com/apache/airflow/pull/70570); `value_schema` for
mapped bindings is
+tracked in [#70523](https://github.com/apache/airflow/issues/70523).
+
+### Alternatives Considered
+
+- **Hand-written XCom pulls in the foreign runtime (status quo).** Rejected:
duplicates the Dag's
+ wiring in a second language, hardcodes upstream `task_id`s far from the Dag
file, and gives the
+ author no reason to believe the TaskFlow call they wrote does anything.
+- **Capture the spec in the standard provider's stub decorator.** Rejected:
splits the producer
+ from the schema it targets, and puts type-hint-to-JSON-Schema derivation in
a provider
+ (decision B).
+- **Recognise stub tasks by operator class name in the Execution API.**
Rejected: forces every
+ future foreign-runtime operator to duplicate a magic string (decision B).
+- **A bespoke `data_type` enum for argument types.** Rejected: re-implemented
per SDK, closed
+ vocabulary, does not compose (decision D).
+- **Deliver the spec only in the serialized Dag, with no `ti_run` field.**
Rejected: cannot
+ express per-map-index values and offers no version-negotiation point
(decision F).
+- **Omit `value_schema` on XCom bindings.** Rejected: makes decoding a
property of each SDK
+ rather than of the Dag (decision E).
+
+## Consequences
+
+- Every Lang SDK binds against one contract, and gains the same behavior when
native Lang-SDK
+ Dag authoring arrives (decision G). The cost is that each new SDK owes a
binding
+ implementation; a runtime that ignores `arg_bindings` degrades to today's
behavior rather than
+ breaking.
+- Unsupported TaskFlow forms fail when the Dag is serialized, with an
actionable message, instead
+ of surfacing as a decode error inside a foreign runtime where the author has
the least context.
+- The flip side: there is now a set of TaskFlow constructs that work in Python
and not on stub
+ tasks (decision H). Authors moving a task across the boundary may have to
restructure a call,
+ and the boundary is not visible from the Dag file alone.
+- `value_schema` is advisory. Nothing enforces that an upstream's payload
matches the downstream
+ parameter's schema; the runtime is free to reject on decode. Enforcement at
Dag-processing time
+ becomes possible after AIP-85 but is not part of this decision.
+- The mechanism is not private to `@task.stub`: any TaskFlow-shaped operator
that sets `is_stub`
+ gets arg-binding materialization. A stub-flagged operator that is not a
`DecoratedOperator` is
+ recognized as stub-backed but receives no bindings (decision B).
+- Older clients are unaffected: the version migration strips `arg_bindings`,
and the server skips
+ deriving it for them entirely.
+
+## Appendix: Implementation Notes
+
+Mechanics and non-obvious details, recorded so they are not re-derived or
re-litigated. These are
+consequences of the decisions above, not decisions in their own right.
+
+**Where the pieces live**
+
+| Concern | Location |
+| --- | --- |
+| Spec builder | `airflow-core/src/airflow/serialization/stub_arg_bindings.py`
|
+| Wire model (`TaskArgBinding` union) |
`airflow-core/src/airflow/api_fastapi/execution_api/datamodels/task_arg_binding.py`
|
+| `ti_run` derivation |
`airflow-core/src/airflow/api_fastapi/execution_api/services/task_instances.py`
|
+| Execution API version |
`airflow-core/src/airflow/api_fastapi/execution_api/versions/v2026_10_30.py` |
+| Supervisor schema version |
`task-sdk/src/airflow/sdk/execution_time/schema/versions/v2026_10_30.py` |
+| Stub marker |
`providers/standard/src/airflow/providers/standard/decorators/stub.py` |
+
+**Details worth knowing**
+
+- **`kind` is a bare `Literal` with no default, deliberately.** Giving it a
default drops it from
+ the OpenAPI schema's `required` list; `datamodel-code-generator` then types
the generated
+ task-sdk client field as `Literal | None`, which pydantic rejects as a
tagged-union
+ discriminator, and `import airflow.sdk` fails at class-definition time. A
plain default and
+ `Field(init=False, default=...)` were both tested against the real generator
and both fail this
+ way. Every entry is built server-side as a plain dict, so no call site wants
the default anyway.
+- **Key omission, never `null`, is the wire contract for "unconstrained".**
`ti_run` responds with
+ `exclude_unset`, so an absent key stays absent.
+- **`value_schema` generation.** Pydantic's stock JSON-schema generation, plus
OpenAPI's
+ `int64`/`double` numeric formats — a typed runtime decoding into a machine
type cannot get the
+ width from the bare `integer`/`number` type names, and `format` is an
annotation per JSON
+ Schema, so runtimes that do not recognize them simply skip them. Temporal
subclasses such as
+ `pendulum.DateTime` are normalized to their stdlib bases on retry, so they
schema as
+ `date-time` rather than being dropped. Generation is cached per annotation
(`TypeAdapter`
+ construction is expensive and annotations are static) and degrades to no
schema rather than
+ failing Dag serialization.
+- **The builder is imported lazily**, so Python-only deployments never pay for
pydantic's
+ JSON-schema machinery just to serialize a Dag.
+- **`is_stub` and `_arg_bindings` both bypass the generic `{__type, __var}`
encoding** on decode,
+ because the spec is plain JSON. `is_stub` additionally fails closed —
anything that is not JSON
+ `true` means "not a stub" — since a non-Python producer's blob is never
schema-validated on that
+ path. `_arg_bindings` is restored verbatim and validated later, at `ti_run`,
where a malformed
+ spec becomes a 500 rather than a silently wrong binding.
+- **Version gating uses Cadwyn's `VersionChangeWithSideEffects.is_applied`**,
not a date
+ comparison, so the server skips the derivation entirely for older clients
rather than computing
+ a value the migration will strip.
+- **No serialization schema version bump.** The change is purely additive,
+ `definitions.operator` allows additional properties, and schema validation
only runs on the
+ write path, so nothing on the read side changes for an existing blob.
Reviewers noted that the
+ serialization version has been effectively unused since Airflow 2 and may be
worth making
+ meaningful; that policy question is tracked in
+ [#71364](https://github.com/apache/airflow/issues/71364).
diff --git a/airflow-core/adr/lang-sdk/README.md
b/airflow-core/adr/lang-sdk/README.md
index 7092bc19f31..ba7800a9740 100644
--- a/airflow-core/adr/lang-sdk/README.md
+++ b/airflow-core/adr/lang-sdk/README.md
@@ -32,6 +32,7 @@ bind core interfaces and apply to every language SDK, not
just the Java SDK.
- [ADR-0004](0004-dag-parsing.md): language-specific Dag file processing.
- [ADR-0005](0005-coordinator-packaging.md): coordinator packaging, module
layout, and registration.
- [ADR-0006](0006-no-lang-sdk-source-display.md): no Lang-SDK source display
for mixed-language (`@task.stub`) Dags.
+- [ADR-0007](0007-taskflow-across-language-boundary.md): TaskFlow across the
language boundary — argument binding for Lang-SDK tasks.
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).