Skip to content

Let XCom backends tell loop iterations apart through store_value - #74858

Merged
ashb merged 2 commits into
apache:mainfrom
astronomer:xcom-loop-coordinates
Oct 18, 2026
Merged

ashb merged 2 commits into
apache:mainfrom
astronomer:xcom-loop-coordinates

Conversation

@ashb

@ashb ashb commented Oct 16, 2026

Copy link
Copy Markdown
Member

Every iteration of a task inside a loop is its own task instance, but an XCom backend's serialize_value only receives dag_id, run_id, task_id, map_index and key, which is shared by different iterations of the same task. A backend might naievly derives its storage path from those values would every iteration to the same location, so every read would give back the last iteration's value n times. It only shows when the XComs are read from outside the loop, which makes it easy to miss.

To work around this, without breaking old XCom backends, I have introduced a new method on the backend, store_value, and instead of 4 (or 5 or 6) arguments it takes a single arg of type XComCoordinate to prevent arg sprawl and to make this new fn future proof.

XCom.set on another task (i.e. passign a different task_id) cannot know which task instance the server will resolve its coordinates to, so there the id is unknown and the location has to be unique to the write. Leaving that distinction to each backend would make the collision easy to reintroduce, so XComCoordinate.build_path builds the location for both cases and shortens the readable parts only when the path would exceed the backend-provided key limit. serialize_value already combined converting a value with writing it somewhere, and one more keyword would have kept growing its signature. For each old and new hook fn, the override closest to the class wins, so a subclass of a store_value backend that overrides serialize_value keeps its override. Airflow can't tell whether a legacy backend's locations are unique, so implementing store_value is what marks a backend as safe in a loop: a Dag that uses a loop fails at startup under a serialize_value-only backend instead of overwriting results silently.

Writes and deletes of another task's XCom did not follow the rule reads
use: they resolved to the caller's own task instance or the single live
one, and failed with 409 when several iterations of a loop task were live.
Resolving them like reads, from the calling task instance, lets a task in
a loop write its own iteration of a sibling and gives a caller outside the
loop the same 400 a read gets.

The backend's store and load talk to external storage, so the async paths,
which share an event loop with the triggerer's other triggers, run them in
a thread.

The trigger runner already carried its task instance id in a context
variable so the API could resolve a trigger's requests. It moved into the
Task SDK and holds the task instance, because the SDK has to recognise when
a trigger writes its own coordinates and then pass the id and region on.

The common.io ObjectStore backend now builds its object paths with build_path, so
they now sit under ti-<id> or write-<rand_uuid>. Rows already written keep
working because they store full paths in the metadata, and read just reads the
path it is given.

Overwritten and failed writes still leave their old objects behind, as they
do today; cleaning those up is separate work. The configured backend also
applies only to the XComs that Python tasks and triggers write and read.
Tasks written in other languages still store theirs directly in the
database, and fixing that needs the supervisor to apply the backend for
them. Again, this doesn't change from today.


Was generative AI tooling used to co-author this PR?
  • Yes (please specify the tool below)

  • Read the Pull Request Guidelines for more information. Note: commit author/co-author name and email in commits become permanently public when merged.
  • For fundamental code changes, an Airflow Improvement Proposal (AIP) is needed.
  • When adding dependency, check compliance with the ASF 3rd Party License Policy.
  • For significant user-facing changes create newsfragment: {pr_number}.significant.rst, in airflow-core/newsfragments. You can add this file in a follow-up commit after the PR is created so you know the PR number.

Comment thread task-sdk/src/airflow/sdk/bases/xcom.py Resolved
ashb added 2 commits October 18, 2026 11:16
Every iteration of a task inside a loop is its own task instance, but an XCom
backend's `serialize_value` only receives dag_id, run_id, task_id, map_index
and key, which is shared by different iterations of the same task. A backend
might naievly derives its storage path from those values would every iteration
to the same location, so every read would give back the last iteration's value
n times. It only shows when the XComs are read from outside the loop, which
makes it easy to miss.

To work around this, without breaking old XCom backends, I have introduced
a new method on the backend, `store_value`, and instead of 4 (or 5 or 6)
arguments it takes a single arg of type `XComCoordinate` to prevent arg sprawl
and to make this new fn future proof.

`XCom.set` on another task (i.e. passign a different `task_id`) cannot know
which task instance the server will resolve its coordinates to, so there the
id is unknown and the location has to be unique to the write. Leaving that
distinction to each backend would make the collision easy to reintroduce, so
`XComCoordinate.build_path` builds the location for both cases and shortens
the readable parts only when the path would exceed the backend-provided key
limit. `serialize_value` already combined converting a value with writing
it somewhere, and one more keyword would have kept growing its signature.
For each old and new hook fn, the override closest to the class wins, so a
subclass of a store_value backend that overrides serialize_value keeps its
override. Airflow can't tell whether a legacy backend's locations are unique,
so implementing store_value is what marks a backend as safe in a loop: a Dag
that uses a loop fails at startup under a serialize_value-only backend instead
of overwriting results silently.

Writes and deletes of another task's XCom did not follow the rule reads
use: they resolved to the caller's own task instance or the single live
one, and failed with 409 when several iterations of a loop task were live.
Resolving them like reads, from the calling task instance, lets a task in
a loop write its own iteration of a sibling and gives a caller outside the
loop the same 400 a read gets.

The backend's store and load talk to external storage, so the async paths,
which share an event loop with the triggerer's other triggers, run them in
a thread.

The trigger runner already carried its task instance id in a context
variable so the API could resolve a trigger's requests. It moved into the
Task SDK and holds the task instance, because the SDK has to recognise when
a trigger writes its own coordinates and then pass the id and region on.

The common.io ObjectStore backend now builds its object paths with build_path, so
they now sit under `ti-<id>` or `write-<rand_uuid>`. Rows already written keep
working because they store full paths in the metadata, and read just reads the
path it is given.

Overwritten and failed writes still leave their old objects behind, as they
do today; cleaning those up is separate work. The configured backend also
applies only to the XComs that Python tasks and triggers write and read.
Tasks written in other languages still store theirs directly in the
database, and fixing that needs the supervisor to apply the backend for
them. Again, this doesn't change from today.
@ashb
ashb force-pushed the xcom-loop-coordinates branch from da27c6d to c16d7be Compare October 18, 2026 15:20
@ashb
ashb merged commit a27f8e7 into apache:main Oct 18, 2026
159 checks passed
@ashb
ashb deleted the xcom-loop-coordinates branch October 18, 2026 16:23
@github-actions github-actions Bot added this to the Airflow 3.4.1 milestone Oct 18, 2026
@github-actions

Copy link
Copy Markdown
Contributor

Hi maintainer, this PR was merged without a milestone set.
We've automatically set the milestone to Airflow 3.4.1 based on: backport label targeting v3-4-test
If this milestone is not correct, please update it to the appropriate milestone.

This comment was generated by Milestone Tag Assistant.

@github-actions

Copy link
Copy Markdown
Contributor

Backport successfully created: v3-4-test

Note: As of Merging PRs targeted for Airflow 3.X
the committer who merges the PR is responsible for backporting the PRs that are bug fixes (generally speaking) to the maintenance branches.

In matter of doubt please ask in #release-management Slack channel.

Status Branch Result
✅ v3-4-test PR Link

ashb added a commit that referenced this pull request Oct 18, 2026
…e_value (#74858) (#75011)

Every iteration of a task inside a loop is its own task instance, but an XCom
backend's `serialize_value` only receives dag_id, run_id, task_id, map_index
and key, which is shared by different iterations of the same task. A backend
might naievly derives its storage path from those values would every iteration
to the same location, so every read would give back the last iteration's value
n times. It only shows when the XComs are read from outside the loop, which
makes it easy to miss.

To work around this, without breaking old XCom backends, I have introduced
a new method on the backend, `store_value`, and instead of 4 (or 5 or 6)
arguments it takes a single arg of type `XComCoordinate` to prevent arg sprawl
and to make this new fn future proof.

`XCom.set` on another task (i.e. passign a different `task_id`) cannot know
which task instance the server will resolve its coordinates to, so there the
id is unknown and the location has to be unique to the write. Leaving that
distinction to each backend would make the collision easy to reintroduce, so
`XComCoordinate.build_path` builds the location for both cases and shortens
the readable parts only when the path would exceed the backend-provided key
limit. `serialize_value` already combined converting a value with writing
it somewhere, and one more keyword would have kept growing its signature.
For each old and new hook fn, the override closest to the class wins, so a
subclass of a store_value backend that overrides serialize_value keeps its
override. Airflow can't tell whether a legacy backend's locations are unique,
so implementing store_value is what marks a backend as safe in a loop: a Dag
that uses a loop fails at startup under a serialize_value-only backend instead
of overwriting results silently.

Writes and deletes of another task's XCom did not follow the rule reads
use: they resolved to the caller's own task instance or the single live
one, and failed with 409 when several iterations of a loop task were live.
Resolving them like reads, from the calling task instance, lets a task in
a loop write its own iteration of a sibling and gives a caller outside the
loop the same 400 a read gets.

The backend's store and load talk to external storage, so the async paths,
which share an event loop with the triggerer's other triggers, run them in
a thread.

The trigger runner already carried its task instance id in a context
variable so the API could resolve a trigger's requests. It moved into the
Task SDK and holds the task instance, because the SDK has to recognise when
a trigger writes its own coordinates and then pass the id and region on.

The common.io ObjectStore backend now builds its object paths with build_path, so
they now sit under `ti-<id>` or `write-<rand_uuid>`. Rows already written keep
working because they store full paths in the metadata, and read just reads the
path it is given.

Overwritten and failed writes still leave their old objects behind, as they
do today; cleaning those up is separate work. The configured backend also
applies only to the XComs that Python tasks and triggers write and read.
Tasks written in other languages still store theirs directly in the
database, and fixing that needs the supervisor to apply the backend for
them. Again, this doesn't change from today.
(cherry picked from commit a27f8e7)

Co-authored-by: Ash Berlin-Taylor <ash@astronomer.io>
Co-authored-by: Rahul Vats <43964496+vatsrahul1001@users.noreply.github.com>
@Dev-iL Dev-iL mentioned this pull request Oct 19, 2026
1 task done
@Dev-iL Dev-iL mentioned this pull request Oct 19, 2026
1 task done
vatsrahul1001 added a commit that referenced this pull request Oct 20, 2026
…e_value (#74858) (#75011)

Every iteration of a task inside a loop is its own task instance, but an XCom
backend's `serialize_value` only receives dag_id, run_id, task_id, map_index
and key, which is shared by different iterations of the same task. A backend
might naievly derives its storage path from those values would every iteration
to the same location, so every read would give back the last iteration's value
n times. It only shows when the XComs are read from outside the loop, which
makes it easy to miss.

To work around this, without breaking old XCom backends, I have introduced
a new method on the backend, `store_value`, and instead of 4 (or 5 or 6)
arguments it takes a single arg of type `XComCoordinate` to prevent arg sprawl
and to make this new fn future proof.

`XCom.set` on another task (i.e. passign a different `task_id`) cannot know
which task instance the server will resolve its coordinates to, so there the
id is unknown and the location has to be unique to the write. Leaving that
distinction to each backend would make the collision easy to reintroduce, so
`XComCoordinate.build_path` builds the location for both cases and shortens
the readable parts only when the path would exceed the backend-provided key
limit. `serialize_value` already combined converting a value with writing
it somewhere, and one more keyword would have kept growing its signature.
For each old and new hook fn, the override closest to the class wins, so a
subclass of a store_value backend that overrides serialize_value keeps its
override. Airflow can't tell whether a legacy backend's locations are unique,
so implementing store_value is what marks a backend as safe in a loop: a Dag
that uses a loop fails at startup under a serialize_value-only backend instead
of overwriting results silently.

Writes and deletes of another task's XCom did not follow the rule reads
use: they resolved to the caller's own task instance or the single live
one, and failed with 409 when several iterations of a loop task were live.
Resolving them like reads, from the calling task instance, lets a task in
a loop write its own iteration of a sibling and gives a caller outside the
loop the same 400 a read gets.

The backend's store and load talk to external storage, so the async paths,
which share an event loop with the triggerer's other triggers, run them in
a thread.

The trigger runner already carried its task instance id in a context
variable so the API could resolve a trigger's requests. It moved into the
Task SDK and holds the task instance, because the SDK has to recognise when
a trigger writes its own coordinates and then pass the id and region on.

The common.io ObjectStore backend now builds its object paths with build_path, so
they now sit under `ti-<id>` or `write-<rand_uuid>`. Rows already written keep
working because they store full paths in the metadata, and read just reads the
path it is given.

Overwritten and failed writes still leave their old objects behind, as they
do today; cleaning those up is separate work. The configured backend also
applies only to the XComs that Python tasks and triggers write and read.
Tasks written in other languages still store theirs directly in the
database, and fixing that needs the supervisor to apply the backend for
them. Again, this doesn't change from today.
(cherry picked from commit a27f8e7)

Co-authored-by: Ash Berlin-Taylor <ash@astronomer.io>
Co-authored-by: Rahul Vats <43964496+vatsrahul1001@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants