Skip to content

Commit ad93457

Browse files
committed
Delete archived placeholder attempts when a mapped task expands
Clearing a mapped task's upstream archives its map_index -1 placeholder and inserts a new current one. When that placeholder is later promoted to index 0 or deleted, its archived attempts were left without a current attempt, which made the 3.4 downgrade refuse in ordinary use. Delete them at expansion, and only for a placeholder that was retried so the common path adds no query. Also bind the downgrade's owner insert for a task first run after the upgrade, check that refused downgrades leave the schema untouched, make the expansion query-count pin exact, and shorten the release note.
1 parent 73d1829 commit ad93457

6 files changed

Lines changed: 116 additions & 6 deletions

File tree

‎RELEASE_NOTES.rst‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ Airflow 3.4.0 (unreleased)
2828
--------------------------
2929

3030
.. note::
31-
Downgrading from this release to 3.4.0 can be slow, depending on the number of
31+
Downgrading to any version earlier than 3.4.0 can be slow, depending on the number of
3232
XCom rows that have been written after upgrading. Upgrading was tested on a
3333
300m row XCom table, so please don't worry about upgrade time (we made sure
3434
that the XCom *data* didn't get touched or read during migration to keep it
@@ -39,6 +39,10 @@ Airflow 3.4.0 (unreleased)
3939
to the old table, and there is no way of doing this without copying the
4040
written xcom values.
4141

42+
Only the XCom and rendered fields of each task instance's latest attempt are
43+
carried back; those of earlier attempts are dropped. The downgrade stops
44+
without changing anything if it finds data it cannot map back safely.
45+
4246
Airflow 3.3.2 (2026-09-17)
4347
--------------------------
4448

‎airflow-core/src/airflow/models/taskinstance.py‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2581,6 +2581,20 @@ def expand_mapped_task(self, *, session: Session) -> tuple[Sequence[TaskInstance
25812581
else:
25822582
dr = unmapped_ti.dag_run
25832583
state = unmapped_ti.state
2584+
if unmapped_ti.try_number > 0:
2585+
# Archived attempts only exist once the placeholder has been retried. They would have
2586+
# no current attempt left once it moves to index 0 or is deleted.
2587+
session.execute(
2588+
delete(TaskInstance)
2589+
.where(
2590+
TaskInstance.dag_id == task.dag_id,
2591+
TaskInstance.task_id == task.task_id,
2592+
TaskInstance.run_id == run_id,
2593+
TaskInstance.map_index == -1,
2594+
TaskInstance.working_set.is_(None),
2595+
)
2596+
.execution_options(synchronize_session=False, include_all_attempts=True)
2597+
)
25842598
zero_index_ti_exists = exists_query(
25852599
TaskInstance.working_set.is_(True),
25862600
TaskInstance.dag_id == task.dag_id,

‎airflow-core/tests/unit/migrations/test_0142_unify_task_attempt_ownership.py‎

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -721,6 +721,13 @@ def drop_unique_constraint(connection, name):
721721
connection.exec_driver_sql("PRAGMA foreign_keys=ON")
722722

723723

724+
def assert_upgraded_schema_untouched(connection):
725+
tables = sa.inspect(connection).get_table_names()
726+
assert {"xcom_v1", "legacy_task_data_owner"} <= set(tables)
727+
assert "task_instance_history" not in tables
728+
assert "hitl_detail_history" not in tables
729+
730+
724731
@pytest.mark.parametrize("legacy_store", ["xcom", "rendered_task_instance_fields"])
725732
def test_downgrade_rejects_owner_that_changed_coordinates(populated_predecessor, legacy_store):
726733
connection, config = populated_predecessor
@@ -734,6 +741,7 @@ def test_downgrade_rejects_owner_that_changed_coordinates(populated_predecessor,
734741
connection.commit()
735742
with pytest.raises(RuntimeError, match="legacy owners changed coordinates"):
736743
command.downgrade(config, PREDECESSOR)
744+
assert_upgraded_schema_untouched(connection)
737745

738746

739747
def test_downgrade_deletes_stale_owner_of_an_attempt_retried_after_promotion(populated_predecessor):
@@ -788,6 +796,7 @@ def test_downgrade_rejects_attempts_sharing_coordinates(populated_predecessor, d
788796
connection.commit()
789797
with pytest.raises(RuntimeError, match="multiple attempts sharing coordinates"):
790798
command.downgrade(config, PREDECESSOR)
799+
assert_upgraded_schema_untouched(connection)
791800

792801

793802
def test_downgrade_rejects_archived_attempts_without_a_current_attempt(populated_predecessor):
@@ -802,6 +811,30 @@ def test_downgrade_rejects_archived_attempts_without_a_current_attempt(populated
802811
connection.commit()
803812
with pytest.raises(RuntimeError, match="1 archived attempts"):
804813
command.downgrade(config, PREDECESSOR)
814+
assert_upgraded_schema_untouched(connection)
815+
816+
817+
def test_downgrade_keeps_only_the_latest_attempt_of_a_task_first_run_after_upgrade(populated_predecessor):
818+
connection, config = populated_predecessor
819+
command.upgrade(config, REVISION)
820+
coordinates = COORDINATES | {"task_id": "neighbour"}
821+
first_id = insert_attempt(connection, **coordinates, working_set=None, archived_reason="retry")
822+
second_id = insert_attempt(connection, **coordinates, try_number=2)
823+
for attempt_id, value in ((first_id, "first"), (second_id, "second")):
824+
insert_xcom_v2(connection, attempt_id, "return_value", value)
825+
insert_rtif_v2(connection, attempt_id, {"f": value})
826+
connection.commit()
827+
828+
command.downgrade(config, PREDECESSOR)
829+
830+
assert {key: row.value for key, row in read_legacy_xcom(connection).items() if key[2] == "neighbour"} == {
831+
("ownership", "manual", "neighbour", -1, "return_value"): "second"
832+
}
833+
assert {
834+
key: row.rendered_fields
835+
for key, row in read_legacy_rendered_fields(connection).items()
836+
if key[2] == "neighbour"
837+
} == {("ownership", "manual", "neighbour", -1): {"f": "second"}}
805838

806839

807840
def test_downgrade_replaces_legacy_xcom_key_by_key(populated_predecessor):

‎airflow-core/tests/unit/models/test_mappedoperator.py‎

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -228,6 +228,66 @@ def test_expand_mapped_task_failed_state_in_db(dag_maker, session):
228228
queue_workload.assert_called_once()
229229

230230

231+
@pytest.mark.parametrize("zero_index_exists", [False, True])
232+
def test_expand_mapped_task_deletes_archived_attempts_of_the_placeholder(
233+
dag_maker, session, zero_index_exists
234+
):
235+
with dag_maker(session=session, serialized=True) as dag:
236+
task1 = BaseOperator(task_id="op1")
237+
other = BaseOperator(task_id="other")
238+
mapped = MockOperator.partial(task_id="task_2").expand(arg2=task1.output)
239+
dr = dag_maker.create_dagrun()
240+
other_dr = dag_maker.create_dagrun(run_id="other_run", logical_date=DEFAULT_DATE + timedelta(days=1))
241+
mapped_deser = dag.task_dict[mapped.task_id]
242+
dr.get_task_instance(mapped.task_id, session=session).prepare_db_for_next_try(
243+
session
244+
).prepare_db_for_next_try(session)
245+
246+
zero_index = TaskInstance(
247+
mapped_deser,
248+
run_id=dr.run_id,
249+
map_index=0,
250+
state=TaskInstanceState.SUCCESS,
251+
dag_version_id=DagVersion.get_latest_version(dr.dag_id).id,
252+
)
253+
session.add(zero_index)
254+
session.flush()
255+
if zero_index_exists:
256+
zero_index.prepare_db_for_next_try(session)
257+
else:
258+
zero_index.archive(reason="retry", session=session)
259+
other_task_ti = dr.get_task_instance(other.task_id, session=session)
260+
other_task_ti.prepare_db_for_next_try(session)
261+
other_run_ti = other_dr.get_task_instance(mapped.task_id, session=session)
262+
other_run_ti.prepare_db_for_next_try(session)
263+
survivors = {zero_index.id, other_task_ti.id, other_run_ti.id}
264+
push_mapped_length(dr.get_task_instance(task1.task_id, session=session), [1, 2], session=session)
265+
session.flush()
266+
267+
expand_mapped_task_instances(mapped_deser, dr.run_id, session=session)
268+
269+
def get_ids(*conditions):
270+
return set(
271+
session.scalars(
272+
select(TaskInstance.id).where(*conditions).execution_options(include_all_attempts=True)
273+
)
274+
)
275+
276+
placeholders = (
277+
TaskInstance.run_id == dr.run_id,
278+
TaskInstance.task_id == mapped.task_id,
279+
TaskInstance.map_index == -1,
280+
)
281+
assert get_ids(*placeholders) == set()
282+
assert get_ids(TaskInstance.id.in_(survivors)) == survivors
283+
current_indexes = session.scalars(
284+
select(TaskInstance.map_index).where(
285+
TaskInstance.run_id == dr.run_id, TaskInstance.task_id == mapped.task_id
286+
)
287+
)
288+
assert set(current_indexes) == {0, 1}
289+
290+
231291
def test_stable_mapped_indexes_do_not_query_historical_max_try(dag_maker, session):
232292
with dag_maker(session=session, serialized=True) as dag:
233293
upstream = BaseOperator(task_id="upstream")

‎airflow-core/tests/unit/models/test_taskinstance.py‎

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3796,9 +3796,8 @@ def show(value):
37963796
show_task = dag_maker.serialized_dag.get_task("show")
37973797
# Pins the query count so a regression back to per-index session.merge() -- which
37983798
# would issue a merge-load + reload SELECT per index -- fails this test, not just
3799-
# a slower one. Measured at 7 for this fixture; margin allows for minor backend
3800-
# differences while staying far below what a per-index merge() would cost.
3801-
with assert_queries_count(7, margin=2):
3799+
# a slower one. Measured at 9 for this fixture.
3800+
with assert_queries_count(9):
38023801
mapped_tis, max_map_index = expand_mapped_task_instances(
38033802
show_task, dag_run.run_id, session=session
38043803
)

‎reproducible_build.yaml‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,2 @@
1-
release-notes-hash: 4fd282c9419f6ba72910b6a2f6102a7c
2-
source-date-epoch: 1791378524
1+
release-notes-hash: d478114b84c9010fc7fc3d87399785b4
2+
source-date-epoch: 1791466565

0 commit comments

Comments
 (0)