Repository navigation
Conversation
Reading XCom values for a mapped task or task group by sequence index or slice treated the position among existing XCom rows as the map_index. When a mapped task instance hasn't pushed an XCom yet (still up_for_reschedule, or skipped), the gap silently shifted every later map_index's value down by one, so downstream tasks could read the wrong value or hit an IndexError even though the upstream task instances existed.
The sparse-index fix for slice reads unconditionally fetched all of a mapped task's XCom rows into memory to place each value at its true map_index. That regressed the common case (no gap) versus the previous SQL-side pagination, and XCom payloads have no size cap -- only the number of mapped instances does. Only fall into the full-fetch path once a gap is actually detected via a COUNT query.
The sparse-index handling only checked whether the task was mapped, not whether the requested key had ever been pushed at all. Querying a wrong or nonexistent key against a mapped task incorrectly took the sparse path and returned every offset as null / the full-length array filled with null, instead of 404 / an empty list.
|
Hello @ColtenOuO - thank you for your contributions to Apache Airflow! The Airflow community has introduced a limit of 5 open pull requests at a time for contributors without write access to the repository. You currently have 19 open pull requests, so - as a one-time step of introducing the limit - we closed the ones where maintainers have not engaged yet:
These pull requests stay open because maintainers are already engaged in them - they count towards your limit:
This is not a judgement of you or of your changes. We never told contributors before that opening many pull requests at once was a problem, so there is nothing to feel bad about - and nothing is lost: your branches, commits and the review history stay where they are. What we ask you to do is to make your first prioritization decision: choose which of the pull requests above matter most to you, and reopen them (up to 5 open at a time, including the ones still open) with the "Reopen pull request" button or While your pull requests are waiting for review, the most valuable thing you can do is help in other ways - reviewing other contributors' pull requests, helping with issues, and taking part in the discussions on the devlist and Slack. Why we introduced the limit, what it means for you and how to reopen or restore a pull request is explained in https://github.com/apache/airflow/blob/main/contributing-docs/32_open_pull_request_limit.rst. Drafted-by: Claude Code (Opus 5); reviewed by @potiuk before posting |
Sumarry
Fix the Execution API's XCom sequence-read endpoints so a mapped task/task-group instance that hasn't pushed an XCom yet (still
up_for_reschedule, or skipped) doesn't shift the values of every mapped instance after it.closes: #40321
The bug
get_mapped_xcom_by_index,get_mapped_xcom_by_slice, andhead_xcom(inairflow-core/src/airflow/api_fastapi/execution_api/routes/xcoms.py) all built their result by querying XCom rows ordered bymap_indexand then using the position in that result set as if it were themap_indexitself (via SQLOFFSET/LIMIT/slice(), withmap_indexdropped from the response).That's correct only when every mapped instance has already pushed an XCom. If one hasn't — e.g. a sensor inside a dynamically mapped
@task_groupis stillup_for_reschedule— there's no row for itsmap_index, so the query silently returns one fewer row and every latermap_index's value shifts down by one position. A downstream task readingmapped_group.output[i](or iterating over it) can get another instance's value instead of the one it asked for, or hit anIndexErrorreading past the (now short) end.This is the same root cause reported in #40321 in 2024 against the Airflow 2.x
LazyXComSelectSequenceimplementation. The AIP-72 rewrite reimplemented the same "position == map_index" assumption in the new task-sdk/execution-API code path instead of fixing it, so it's still present onmaintoday.Confirming the bug is still there, and that the fix resolves it
Before writing the fix, I reproduced the bug against current
mainwith a test scenario matching the issue: a task mapped over 4 values, wheremap_index=1never gets an XCom row (simulating a mapped instance that's stillup_for_rescheduleand hasn't pushed anything). Querying the slice endpoint for all 4 values returned only 3 —["f", "o", "b"]— with the value that actually belongs tomap_index=3silently shifted intomap_index=1's position. TheHEADendpoint's count also undercounted (3 instead of 4). This matches exactly what the issue reports: values read by a downstream task line up with the wrongmap_index, and the reported length is wrong too.Two of the existing tests in
test_xcoms.py(test_xcom_get_with_offset,test_xcom_get_with_slice) already built this exact gapped scenario, but asserted the compacted 3-item result as the expected outcome — meaning the buggy behavior was pinned as correct by the test suite rather than being an untested gap.After the fix, I reran the same scenario: the slice endpoint now returns the full 4-item
["f", None, "o", "b"], withmap_index=3's value correctly in position 3, andHEAD's count correctly reports 4. I updated the two pinning tests to assert this corrected behavior and addedtest_xcom_get_with_slice_and_count_unfinished_mapped_taskas an explicit regression test for the issue's scenario (a task instance that never pushed an XCom at all, as opposed to one whose pushed value happened to beNone— those are different situations that both need to resolve correctly).The fix
Added
_get_mapped_length(), which resolves the logical number of mapped instances for(dag_id, run_id, task_id)from theTaskInstancetable (every mapped instance gets a row at expansion time, whether or not it has run yet — seeTaskMap.expand_mapped_task), independent of how many XCom rows exist.When a task is mapped in this run, the three endpoints now key results by the actual
map_indexinstead of result position, filling inNonefor anymap_indexthat has no XCom row yet:get_mapped_xcom_by_index:offsetresolves against the logical0..mapped_length-1range; a valid-but-missing index returnsNone(200), not a 404 — only an out-of-range index 404s.get_mapped_xcom_by_slice: builds amap_index -> valuedict, fills gaps withNone, then applies the slice with plain Python slicing.head_xcom: reports the logical mapped length inContent-Range, not the XCom row count.Two cases are deliberately left on the old (pre-fix) behavior, since there's no well-defined "logical length" for them:
include_prior_dates=Trueon the slice endpoint can span multiple dag runs, each with its own (potentially different) mapped length.map_index=-1, no expansion) has no mapped-length concept at all — it falls through to the original row-position logic unchanged.get_xcom'soffsetquery-parameter branch (a separate, older code path) is intentionally untouched — no task-sdk client currently calls it withoffsetset, and it has its own pre-existing "skip None values" semantics that aren't part of this bug.Blast radius: what else calls these endpoints, and what changes for callers
I checked who actually consumes these three routes and what the response-shape change means for them, since this touches a fairly central part of the Execution API.
Consumers.
task-sdk'sLazyXComSequence(task-sdk/src/airflow/sdk/execution_time/lazy_sequence.py) is the only real consumer, reached throughXComOperationsintask-sdk/src/airflow/sdk/api/client.py. I grepped the rest of the repo (providers/, the UI, everywhere else) for these route paths and the relevant symbol names and found no other caller — nothing outside task-sdk talks to these three endpoints directly.client.pyitself is a thin pass-through and doesn't assume anything about the old semantics, so it needed no changes.User-visible behavior change. This is the one worth calling out explicitly: a mapped task's
.outputused to silently compact away gaps when iterated, sliced, or expanded over downstream (for v in t.output,.expand(x=t.output), etc.) — a missing map_index just meant the list was shorter than the actual mapped count, with everything after it shifted into the wrong position. After this fix,.outputis the correct length and gaps show up explicitly asNoneat their true position. This is the intended correctness fix, but it's a behavior change any DAG author relying (knowingly or not) on the old compaction would notice — e.g..expand(x=t.output)now creates an instance withx=Nonefor the gap instead of one fewer instance. Flagging this for review since it's a semantic change, not just a bugfix that's invisible from the outside.Performance. The original version of this fix always fetched every XCom row for the task into a Python dict once a task was detected as mapped, even when there was no gap at all — regressing the existing SQL-side
OFFSET/LIMIT/slice()pagination for the common case. Since XCom values are stored in an unbounded JSON column (only the count of mapped instances is capped, bycore.max_map_length, default 1024), that could mean loading a large number of large payloads into API-server memory for a single-item slice request. Fixed by adding one cheapCOUNTquery to detect whether a gap actually exists before falling into the full-fetch path — the common case (no gaps) keeps the original SQL pagination; only a genuinely sparse sequence pays the cost of loading every row, which is unavoidable since placing values at their truemap_indexrequires knowing all of them. Addedtest_xcom_get_with_slice_mapped_task_without_gap_uses_sql_paginationto cover the no-gap path explicitly, since the existing gap-scenario tests wouldn't have caught a regression here.Was generative AI tooling used to co-author this PR?