This is an automated email from the ASF dual-hosted git repository.
ashb 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 b5ce03962cc Convert finished TIs into a lightweight read-only view
during scheduling decisions (#71737)
b5ce03962cc is described below
commit b5ce03962cc92b38d5047999cacfed42847bec33
Author: Christos Bisias <[email protected]>
AuthorDate: Wed Oct 28 00:54:00 2026 +0200
Convert finished TIs into a lightweight read-only view during scheduling
decisions (#71737)
---
airflow-core/src/airflow/models/dagrun.py | 233 +++++++++++++++++----
.../src/airflow/models/task_coordinates.py | 59 +++++-
airflow-core/src/airflow/ti_deps/dep_context.py | 12 +-
.../ti_deps/deps/not_previously_skipped_dep.py | 3 +-
.../src/airflow/ti_deps/deps/trigger_rule_dep.py | 5 +-
airflow-core/tests/unit/jobs/test_scheduler_job.py | 4 +-
airflow-core/tests/unit/models/test_dagrun.py | 179 +++++++++++++++-
.../tests/unit/models/test_dynamic_region.py | 2 +-
.../tests/unit/models/test_mappedoperator.py | 9 +-
.../tests/unit/models/test_taskinstance.py | 8 +-
10 files changed, 447 insertions(+), 67 deletions(-)
diff --git a/airflow-core/src/airflow/models/dagrun.py
b/airflow-core/src/airflow/models/dagrun.py
index ac0a4f03487..7be6c87be56 100644
--- a/airflow-core/src/airflow/models/dagrun.py
+++ b/airflow-core/src/airflow/models/dagrun.py
@@ -23,6 +23,7 @@ import os
import re
from collections import defaultdict
from collections.abc import Callable, Iterable, Iterator, Sequence
+from dataclasses import dataclass, field, fields, replace
from datetime import datetime
from typing import TYPE_CHECKING, Any, NamedTuple, TypeVar, cast, overload
from uuid import UUID
@@ -120,8 +121,8 @@ if TYPE_CHECKING:
from typing import Literal, TypeAlias
from pydantic import NonNegativeInt
- from sqlalchemy.engine import ScalarResult
- from sqlalchemy.orm import Session
+ from sqlalchemy.engine import Row, ScalarResult
+ from sqlalchemy.orm import InstrumentedAttribute, Session
from sqlalchemy.sql.elements import ColumnElement
from sqlalchemy.sql.selectable import Select
@@ -130,6 +131,7 @@ if TYPE_CHECKING:
TaskInstance as TIDataModel,
)
from airflow.models.dag_version import DagVersion
+ from airflow.models.task_coordinates import SchedulingTI
from airflow.sdk import DAG as SDKDAG
from airflow.serialization.definitions.dag import SerializedDAG
from airflow.serialization.definitions.mappedoperator import Operator
@@ -163,14 +165,57 @@ def validate_loop_decision(stop: bool | None,
region_index: int, group: Serializ
raise InvalidLoopDecision("Fixed-count loop decision does not match
its iteration limit")
+@dataclass(frozen=True, slots=True, eq=False)
+class FinishedTI:
+ """
+ Immutable view of a task instance in a terminal state.
+
+ Keeps the dag_run rescan lightweight and fast: scheduling only reads these
columns
+ off finished task instances, so a fully instrumented TaskInstance per row
is
+ unnecessary. ``task`` is attached from the serialized dag rather than the
row.
+
+ ``eq=False`` keeps identity equality and ``__hash__`` mirrors the key
hash, the same as
+ a TaskInstance.
+
+ Anything needing a full TaskInstance must re-fetch it (see
``_ensure_type_task_instance``).
+ """
+
+ id: UUID
+ dag_id: str
+ run_id: str
+ task_id: str
+ region_id: UUID
+ region_index: int
+ dag_version_id: UUID | None
+ state: str | None
+ start_date: datetime | None
+ end_date: datetime | None
+ task: Operator | None = field(default=None, kw_only=True)
+
+ @property
+ def map_index(self) -> int:
+ """Mirror the ``TaskInstance.map_index`` synonym of
``region_index``."""
+ return self.region_index
+
+ def __hash__(self) -> int:
+ return hash((self.task_id, self.dag_id, self.run_id,
self.region_index))
+
+
+# Get the necessary columns from the FinishedTI fields so that the class stays
+# the single source of truth. For any needed change, add or remove a field
there.
+# Rows are unpacked into FinishedTI positionally, so the column fields must
stay
+# first and in declaration order, with ``task`` last.
+FINISHED_TI_COLUMNS = tuple(getattr(TI, f.name) for f in fields(FinishedTI) if
f.name != "task")
+
+
class TISchedulingDecision(NamedTuple):
"""Type of return for DagRun.task_instance_scheduling_decisions."""
- tis: list[TI]
+ tis: list[SchedulingTI]
schedulable_tis: list[TI]
changed_tis: bool
unfinished_tis: list[TI]
- finished_tis: list[TI]
+ finished_tis: list[SchedulingTI]
def _default_run_after(ctx):
@@ -978,6 +1023,30 @@ class DagRun(Base, LoggingMixin):
return
DagRunType(run_type).generate_run_id(suffix=run_after.isoformat())
return
DagRunType(run_type).generate_run_id(suffix=f"{run_after.isoformat()}_{get_random_string()}")
+ @overload
+ @staticmethod
+ def fetch_task_instances(
+ dag_id: str | None = ...,
+ run_id: str | None = ...,
+ task_ids: list[str] | None = ...,
+ state: TaskInstanceState | Iterable[TaskInstanceState | None] | None =
...,
+ *,
+ columns: None = None,
+ session: Session = ...,
+ ) -> list[TI]: ...
+
+ @overload
+ @staticmethod
+ def fetch_task_instances(
+ dag_id: str | None = ...,
+ run_id: str | None = ...,
+ task_ids: list[str] | None = ...,
+ state: TaskInstanceState | Iterable[TaskInstanceState | None] | None =
...,
+ *,
+ columns: Sequence[InstrumentedAttribute[Any]],
+ session: Session = ...,
+ ) -> Sequence[Row[Any]]: ...
+
@staticmethod
@provide_session
def fetch_task_instances(
@@ -986,36 +1055,40 @@ class DagRun(Base, LoggingMixin):
task_ids: list[str] | None = None,
state: TaskInstanceState | Iterable[TaskInstanceState | None] | None =
None,
*,
+ columns: Sequence[InstrumentedAttribute[Any]] | None = None,
session: Session = NEW_SESSION,
- ) -> list[TI]:
- """Return the task instances for this dag run."""
- tis = (
- select(TI)
- .options(joinedload(TI.dag_run))
- .where(
- TI.dag_id == dag_id,
- TI.run_id == run_id,
- )
+ ) -> list[TI] | Sequence[Row[Any]]:
+ """
+ Return the task instances for this dag run.
+
+ If ``columns`` is provided then the result is plain rows with the
+ values of these columns, otherwise the result is ORM objects.
+ """
+ stmt = (
+ (select(*columns) if columns else
select(TI).options(joinedload(TI.dag_run)))
+ .where(TI.dag_id == dag_id, TI.run_id == run_id)
.order_by(TI.task_id, TI.region_id, TI.region_index)
)
if state:
if isinstance(state, str):
- tis = tis.where(TI.state == state)
+ stmt = stmt.where(TI.state == state)
else:
# this is required to deal with NULL values
if None in state:
if all(x is None for x in state):
- tis = tis.where(TI.state.is_(None))
+ stmt = stmt.where(TI.state.is_(None))
else:
not_none_state = (s for s in state if s)
- tis = tis.where(or_(TI.state.in_(not_none_state),
TI.state.is_(None)))
+ stmt = stmt.where(or_(TI.state.in_(not_none_state),
TI.state.is_(None)))
else:
- tis = tis.where(TI.state.in_(state))
+ stmt = stmt.where(TI.state.in_(state))
if task_ids is not None:
- tis = tis.where(TI.task_id.in_(task_ids))
- return list(session.scalars(tis).all())
+ stmt = stmt.where(TI.task_id.in_(task_ids))
+ if columns:
+ return session.execute(stmt).all()
+ return list(session.scalars(stmt).all())
def _check_last_n_dagruns_failed(self, dag_id,
max_consecutive_failed_dag_runs, session):
"""Check if last N dags failed."""
@@ -1361,7 +1434,7 @@ class DagRun(Base, LoggingMixin):
callback = self.produce_dag_callback(
dag=dag,
success=False,
- relevant_ti=ti_causing_failure,
+
relevant_ti=self._ensure_type_task_instance(ti_causing_failure,
session=session),
reason="task_failure",
execute=execute_callbacks,
session=session,
@@ -1384,7 +1457,7 @@ class DagRun(Base, LoggingMixin):
self.notify_dagrun_state_changed(msg="success")
if dag.has_on_success_callback:
- last_succeeded_ti: TI | None = max(
+ last_succeeded_ti: SchedulingTI | None = max(
(ti for ti in tis if ti.state ==
TaskInstanceState.SUCCESS),
key=lambda ti: ti.end_date or
timezone.make_aware(datetime.min),
default=None,
@@ -1392,7 +1465,7 @@ class DagRun(Base, LoggingMixin):
callback = self.produce_dag_callback(
dag=dag,
success=True,
- relevant_ti=last_succeeded_ti,
+
relevant_ti=self._ensure_type_task_instance(last_succeeded_ti, session=session),
reason="success",
execute=execute_callbacks,
session=session,
@@ -1480,8 +1553,41 @@ class DagRun(Base, LoggingMixin):
@provide_session
def task_instance_scheduling_decisions(self, *, session: Session =
NEW_SESSION) -> TISchedulingDecision:
- tis = self.get_task_instances(session=session, state=State.task_states)
- self.log.debug("number of tis tasks for %s: %s task(s)", self,
len(tis))
+ # Fetch finished task instances as lightweight read-only rows and only
the
+ # unfinished ones as full ORM objects. Scheduling only reads finished
ones, so
+ # hydrating them is pure overhead.
+ #
+ # Check if there are any pending TI changes.
+ has_pending_ti_changes = any(
+ isinstance(obj, TI) and obj.dag_id == self.dag_id and obj.run_id
== self.run_id
+ for obj in itertools.chain(session.new, session.dirty,
session.deleted)
+ )
+ if has_pending_ti_changes:
+ # Flush here to avoid task instances ending up in the wrong list
or in both,
+ # in case of a pending in-session state change.
+ # The airflow session maker sets autoflush=False (ref
settings.configure_orm).
+ session.flush()
+ # Both queries restrict a partial Dag to the same subset, so no row
outside it is
+ # mistaken for an orphan and marked REMOVED.
+ task_ids = DagRun._get_partial_task_ids(self.dag)
+ unfinished_tis = DagRun.fetch_task_instances(
+ dag_id=self.dag_id,
+ run_id=self.run_id,
+ task_ids=task_ids,
+ state=State.unfinished,
+ session=session,
+ )
+ finished_rows = DagRun.fetch_task_instances(
+ dag_id=self.dag_id,
+ run_id=self.run_id,
+ task_ids=task_ids,
+ state=State.finished,
+ columns=FINISHED_TI_COLUMNS,
+ session=session,
+ )
+ self.log.debug(
+ "number of tis tasks for %s: %s task(s)", self,
len(unfinished_tis) + len(finished_rows)
+ )
def _filter_tis_and_exclude_removed(dag: SerializedDAG, tis: list[TI])
-> Iterable[TI]:
"""Populate ``ti.task`` while excluding those missing one, marking
them as REMOVED."""
@@ -1496,10 +1602,37 @@ class DagRun(Base, LoggingMixin):
else:
yield ti
- tis = list(_filter_tis_and_exclude_removed(self.get_dag(), tis))
+ def _build_finished_tis(dag: SerializedDAG, rows) -> list[FinishedTI]:
+ """Attach the serialized task, dropping (and marking REMOVED) rows
whose task is gone."""
+ finished: list[FinishedTI] = []
+ orphaned: set[str] = set()
+ for row in rows:
+ try:
+ task = dag.get_task(row.task_id)
+ except TaskNotFound:
+ if row.state != TaskInstanceState.REMOVED:
+ orphaned.add(row.task_id)
+ continue
+ finished.append(FinishedTI(*row, task=task))
+ if orphaned:
+ self.log.error("Failed to get task for finished tis %s.
Marking them as removed.", orphaned)
+ session.execute(
+ update(TI)
+ .where(
+ TI.dag_id == self.dag_id,
+ TI.run_id == self.run_id,
+ TI.task_id.in_(orphaned),
+ TI.state != TaskInstanceState.REMOVED,
+ )
+ .values(state=TaskInstanceState.REMOVED)
+ .execution_options(synchronize_session=False)
+ )
+ return finished
+
+ dag = self.get_dag()
+ unfinished_tis = list(_filter_tis_and_exclude_removed(dag,
unfinished_tis))
+ finished_tis: list[SchedulingTI] = list(_build_finished_tis(dag,
finished_rows))
- unfinished_tis = [t for t in tis if t.state in State.unfinished]
- finished_tis = [t for t in tis if t.state in State.finished]
if unfinished_tis:
schedulable_tis = [ut for ut in unfinished_tis if ut.state in
SCHEDULEABLE_STATES]
self.log.debug("number of scheduleable tasks for %s: %s task(s)",
self, len(schedulable_tis))
@@ -1520,6 +1653,7 @@ class DagRun(Base, LoggingMixin):
schedulable_tis = []
changed_tis = False
+ tis: list[SchedulingTI] = [*unfinished_tis, *finished_tis]
return TISchedulingDecision(
tis=tis,
schedulable_tis=schedulable_tis,
@@ -1721,7 +1855,7 @@ class DagRun(Base, LoggingMixin):
def _get_ready_tis(
self,
schedulable_tis: list[TI],
- finished_tis: list[TI],
+ finished_tis: list[SchedulingTI],
session: Session,
) -> tuple[list[TI], bool, bool]:
old_states: dict[UUID, Any] = {}
@@ -1802,10 +1936,20 @@ class DagRun(Base, LoggingMixin):
# checking the map index for each mapped task significantly
slows down scheduling
expansion_key = (schedulable.task.task_id,
schedulable.region_id)
if expansion_key not in revised_expansion_keys:
- revised_tis = self._revise_map_indexes_if_mapped(
+ revised_tis, removed_indexes =
self._revise_map_indexes_if_mapped(
schedulable,
session=session,
)
+ if removed_indexes:
+ # Mirror the bulk UPDATE onto the frozen views, as the
session does for ORM rows.
+ for i, fti in enumerate(finished_tis):
+ if (
+ isinstance(fti, FinishedTI)
+ and fti.task_id == schedulable.task_id
+ and fti.region_id == schedulable.region_id
+ and fti.region_index in removed_indexes
+ ):
+ finished_tis[i] = replace(fti,
state=TaskInstanceState.REMOVED)
ready_tis.extend(revised_tis)
revised_expansion_keys.add(expansion_key)
if revised_tis:
@@ -1827,10 +1971,25 @@ class DagRun(Base, LoggingMixin):
return ready_tis, changed_tis, expansion_happened
+ def _ensure_type_task_instance(self, ti: SchedulingTI | None, *, session:
Session) -> TI | None:
+ """
+ Return ``ti`` as a full TaskInstance, re-fetching it by primary key
when it is a view.
+
+ Full instances (and ``None``) pass through unchanged. Only the
dag-callback path
+ needs the full row, and only when a dag_run reaches a terminal state,
so the
+ re-fetch costs one query per finished dag_run rather than one per loop.
+
+ The lookup is by primary key: a public ``map_index`` lookup would miss
a finished
+ instance that lives in an earlier loop iteration's region.
+ """
+ if ti is None or isinstance(ti, TI):
+ return ti
+ return session.get(TI, ti.id)
+
def _are_premature_tis(
self,
unfinished_tis: Sequence[TI],
- finished_tis: list[TI],
+ finished_tis: list[SchedulingTI],
session: Session,
) -> tuple[bool, bool]:
dep_context = DepContext(
@@ -1846,7 +2005,7 @@ class DagRun(Base, LoggingMixin):
dep_context.have_changed_ti_states,
)
- def _emit_true_scheduling_delay_stats_for_finished_state(self,
finished_tis: list[TI]) -> None:
+ def _emit_true_scheduling_delay_stats_for_finished_state(self,
finished_tis: list[SchedulingTI]) -> None:
"""
Emit the true scheduling delay stats.
@@ -2543,7 +2702,7 @@ class DagRun(Base, LoggingMixin):
ti: TI,
*,
session: Session,
- ) -> list[TI]:
+ ) -> tuple[list[TI], set[int]]:
"""
Check if task increased or reduced in length and handle appropriately.
@@ -2560,7 +2719,7 @@ class DagRun(Base, LoggingMixin):
if task is None:
raise ValueError(f"Task definition is required to revise {ti}")
if not task.get_needs_expansion():
- return []
+ return [], set()
region_id = ti.region_id
dag_version_id = ti.dag_version_id
try:
@@ -2571,9 +2730,9 @@ class DagRun(Base, LoggingMixin):
producer_contexts=TaskCoordinateResolver.for_dag(self.dag,
session).producer_contexts(ti),
)
except NotMapped:
- return [] # Not a mapped task, don't need to do anything.
+ return [], set() # Not a mapped task, don't need to do anything.
except NotFullyPopulated:
- return [] # Upstreams not ready, don't need to revise this yet.
+ return [], set() # Upstreams not ready, don't need to revise this
yet.
query = session.scalars(
select(TI.region_index).where(
@@ -2602,7 +2761,7 @@ class DagRun(Base, LoggingMixin):
missing_indexes = [index for index in range(total_length) if index not
in existing_indexes]
if not missing_indexes:
- return []
+ return [], removed_indexes
last_tries = TI.get_last_try_numbers(
dag_id=self.dag_id,
task_id=task.task_id,
@@ -2629,7 +2788,7 @@ class DagRun(Base, LoggingMixin):
new_tis.append(ti)
if new_tis:
session.flush()
- return new_tis
+ return new_tis, removed_indexes
@classmethod
@provide_session
diff --git a/airflow-core/src/airflow/models/task_coordinates.py
b/airflow-core/src/airflow/models/task_coordinates.py
index fd85cbddc60..83a0b842c10 100644
--- a/airflow-core/src/airflow/models/task_coordinates.py
+++ b/airflow-core/src/airflow/models/task_coordinates.py
@@ -46,12 +46,14 @@ from airflow.utils.state import TaskInstanceState
if TYPE_CHECKING:
from collections.abc import Collection, Iterable, Sequence
+ from datetime import datetime
from sqlalchemy import Select
from sqlalchemy.orm import Session
from sqlalchemy.sql.elements import ColumnElement
from airflow.serialization.definitions.dag import SerializedDAG,
SerializedOperator
+ from airflow.serialization.definitions.mappedoperator import Operator
from airflow.utils.sqlalchemy import UUIDSelect
@@ -73,14 +75,57 @@ class _ProducerRequest:
class TaskCoordinate(Protocol):
- """Stored task identity shared by live and retained task data."""
+ """
+ Stored task identity shared by live and retained task data.
- dag_id: str
- run_id: str
- task_id: str
- region_id: UUID
- region_index: int
- dag_version_id: UUID | None
+ Read-only on purpose: resolvers only ever read coordinates, and declaring
them as
+ properties lets immutable views (such as a frozen dataclass) satisfy the
protocol.
+ """
+
+ @property
+ def dag_id(self) -> str: ...
+
+ @property
+ def run_id(self) -> str: ...
+
+ @property
+ def task_id(self) -> str: ...
+
+ @property
+ def region_id(self) -> UUID: ...
+
+ @property
+ def region_index(self) -> int: ...
+
+ @property
+ def dag_version_id(self) -> UUID | None: ...
+
+
+class SchedulingTI(TaskCoordinate, Protocol):
+ """
+ Shape of a task instance during scheduling, satisfied by ``TaskInstance``
and the frozen ``FinishedTI``.
+
+ Extends the stored coordinates with the identity, state and timing that
scheduling
+ decisions and trigger-rule evaluation read.
+ """
+
+ @property
+ def id(self) -> UUID: ...
+
+ @property
+ def map_index(self) -> int: ...
+
+ @property
+ def state(self) -> str | None: ...
+
+ @property
+ def start_date(self) -> datetime | None: ...
+
+ @property
+ def end_date(self) -> datetime | None: ...
+
+ @property
+ def task(self) -> Operator | None: ...
def enclosing_loop(task: SerializedOperator) -> SerializedLoopTaskGroup | None:
diff --git a/airflow-core/src/airflow/ti_deps/dep_context.py
b/airflow-core/src/airflow/ti_deps/dep_context.py
index 4083793c067..790d47f2c07 100644
--- a/airflow-core/src/airflow/ti_deps/dep_context.py
+++ b/airflow-core/src/airflow/ti_deps/dep_context.py
@@ -31,7 +31,7 @@ if TYPE_CHECKING:
from sqlalchemy.orm.session import Session
from airflow.models.dagrun import DagRun
- from airflow.models.task_coordinates import TaskCoordinateResolver
+ from airflow.models.task_coordinates import SchedulingTI,
TaskCoordinateResolver
from airflow.models.taskinstance import TaskInstance
@@ -82,7 +82,7 @@ class DepContext:
ignore_task_deps: bool = False
ignore_ti_state: bool = False
ignore_unmapped_tasks: bool = False
- finished_tis: list[TaskInstance] | None = None
+ finished_tis: list[SchedulingTI] | None = None
description: str | None = None
have_changed_ti_states: bool = False
@@ -182,20 +182,22 @@ class DepContext:
task mid-pass adds task instances but not skip decisions, so it never
needs invalidating.
"""
- def ensure_finished_tis(self, dag_run: DagRun, session: Session) ->
list[TaskInstance]:
+ def ensure_finished_tis(self, dag_run: DagRun, session: Session) ->
list[SchedulingTI]:
"""
Ensure finished_tis is populated if it's currently None, which allows
running tasks without dag_run.
:param dag_run: The DagRun for which to find finished tasks
:return: A list of all the finished tasks of this DAG and logical_date
"""
+ finished_tis: list[SchedulingTI]
if self.finished_tis is None:
- finished_tis = dag_run.get_task_instances(state=State.finished,
session=session)
- for ti in finished_tis:
+ fetched = dag_run.get_task_instances(state=State.finished,
session=session)
+ for ti in fetched:
if getattr(ti, "task", None) is not None or (dag :=
dag_run.dag) is None:
continue
with contextlib.suppress(TaskNotFound):
ti.task = dag.get_task(ti.task_id)
+ finished_tis = list(fetched)
self.finished_tis = finished_tis
else:
finished_tis = self.finished_tis
diff --git
a/airflow-core/src/airflow/ti_deps/deps/not_previously_skipped_dep.py
b/airflow-core/src/airflow/ti_deps/deps/not_previously_skipped_dep.py
index 80c3380f877..07c438c195a 100644
--- a/airflow-core/src/airflow/ti_deps/deps/not_previously_skipped_dep.py
+++ b/airflow-core/src/airflow/ti_deps/deps/not_previously_skipped_dep.py
@@ -30,6 +30,7 @@ if TYPE_CHECKING:
from sqlalchemy.orm import Session
+ from airflow.models.task_coordinates import SchedulingTI
from airflow.models.taskinstance import TaskInstance
from airflow.serialization.definitions.taskgroup import
SerializedMappedTaskGroup
from airflow.ti_deps.dep_context import DepContext
@@ -211,7 +212,7 @@ def _shares_map_indexes(task, mapped_group:
SerializedMappedTaskGroup) -> bool:
def _mapped_group_skip_decisions(
ti: TaskInstance,
mapped_group: SerializedMappedTaskGroup,
- finished_tis: list[TaskInstance],
+ finished_tis: list[SchedulingTI],
dep_context: DepContext,
session: Session,
) -> dict[int, list[tuple[str, bool, dict]]]:
diff --git a/airflow-core/src/airflow/ti_deps/deps/trigger_rule_dep.py
b/airflow-core/src/airflow/ti_deps/deps/trigger_rule_dep.py
index 8ed5d25e425..ec99c2aa692 100644
--- a/airflow-core/src/airflow/ti_deps/deps/trigger_rule_dep.py
+++ b/airflow-core/src/airflow/ti_deps/deps/trigger_rule_dep.py
@@ -35,6 +35,7 @@ if TYPE_CHECKING:
from sqlalchemy.orm import Session
from sqlalchemy.sql import ColumnElement
+ from airflow.models.task_coordinates import SchedulingTI
from airflow.models.taskinstance import TaskInstance
from airflow.serialization.definitions.mappedoperator import Operator
from airflow.serialization.definitions.taskgroup import SerializedTaskGroup
@@ -59,7 +60,7 @@ class _UpstreamTIStates(NamedTuple):
skipped_setup: int
@classmethod
- def calculate(cls, finished_upstreams: Iterator[TaskInstance]) ->
_UpstreamTIStates:
+ def calculate(cls, finished_upstreams: Iterator[SchedulingTI]) ->
_UpstreamTIStates:
"""
Calculate states for a task instance.
@@ -228,7 +229,7 @@ class TriggerRuleDep(BaseTIDep):
),
)
- def _is_relevant_upstream(upstream: TaskInstance, relevant_ids:
set[str] | KeysView[str]) -> bool:
+ def _is_relevant_upstream(upstream: SchedulingTI, relevant_ids:
set[str] | KeysView[str]) -> bool:
"""
Whether a task instance is a "relevant upstream" of the current
task.
diff --git a/airflow-core/tests/unit/jobs/test_scheduler_job.py
b/airflow-core/tests/unit/jobs/test_scheduler_job.py
index 5ebbb8be9dc..0689ee02d80 100644
--- a/airflow-core/tests/unit/jobs/test_scheduler_job.py
+++ b/airflow-core/tests/unit/jobs/test_scheduler_job.py
@@ -13542,8 +13542,8 @@ class TestSchedulerJobQueriesCount:
# 10 DAGs with 10 tasks per DAG file.
([10, 10, 10, 10], 10, 10, "1d", "None", "no_structure"),
([10, 10, 10, 10], 10, 10, "1d", "None", "linear"),
- ([218, 69, 69, 69], 10, 10, "1d", "@once", "no_structure"),
- ([228, 84, 84, 84], 10, 10, "1d", "@once", "linear"),
+ ([229, 80, 79, 79], 10, 10, "1d", "@once", "no_structure"),
+ ([239, 95, 95, 95], 10, 10, "1d", "@once", "linear"),
([217, 119, 119, 119], 10, 10, "1d", "30m", "no_structure"),
([2227, 145, 145, 145], 10, 10, "1d", "30m", "linear"),
([227, 139, 139, 139], 10, 10, "1d", "30m", "binary_tree"),
diff --git a/airflow-core/tests/unit/models/test_dagrun.py
b/airflow-core/tests/unit/models/test_dagrun.py
index cb39a14303b..8d583b4c0da 100644
--- a/airflow-core/tests/unit/models/test_dagrun.py
+++ b/airflow-core/tests/unit/models/test_dagrun.py
@@ -30,6 +30,7 @@ from uuid import UUID
import pendulum
import pytest
+import time_machine
from opentelemetry import trace as otel_trace
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import SimpleSpanProcessor
@@ -62,6 +63,7 @@ from airflow.models.dagbag import DBDagBag
from airflow.models.dagrun import (
DagRun,
DagRunNote,
+ FinishedTI,
InvalidLoopDecision,
clear_partition_runs,
get_or_create_dagrun,
@@ -789,6 +791,10 @@ class TestDagRun:
# Make sure the correct TI is passed on success
call_args = execute_dag_callbacks.call_args
ti_passed = call_args.kwargs["relevant_ti"]
+ # During scheduling, finished tis are accessed as a minimal FinishedTI
view,
+ # and only the ti handed to a dag callback is fetched as a full
TaskInstance.
+ # Check that the callback receives a TaskInstance and not a FinishedTI.
+ assert isinstance(ti_passed, TaskInstance)
assert ti_passed.task_id == "test_state_succeeded2"
assert dag_run.state == DagRunState.SUCCESS
@@ -823,6 +829,10 @@ class TestDagRun:
# Make sure the correct TI is passed on failure
call_args = execute_dag_callbacks.call_args
ti_passed = call_args.kwargs["relevant_ti"]
+ # During scheduling, finished tis are accessed as a minimal FinishedTI
view,
+ # and only the ti handed to a dag callback is fetched as a full
TaskInstance.
+ # Check that the callback receives a TaskInstance and not a FinishedTI.
+ assert isinstance(ti_passed, TaskInstance)
assert ti_passed.task_id == "test_state_failed2"
assert dag_run.state == DagRunState.FAILED
@@ -3409,9 +3419,15 @@ def test_ti_scheduling_mapped_zero_length(dag_maker,
session):
decision = dr.task_instance_scheduling_decisions(session=session)
- # ti1 finished execution. ti2 goes directly to finished state because it's
- # expanded against a zero-length XCom.
- assert decision.finished_tis == [ti1, ti2]
+ # The finished_tis list contains both FinishedTI and TaskInstance types,
+ # so compare by content rather than identity.
+ #
+ # ti1 finished execution (before the decision - FinishedTI). ti2 goes
directly to finished state
+ # (during the decision - TaskInstance) because it's expanded against a
zero-length XCom.
+ assert [(ti.task_id, ti.state) for ti in decision.finished_tis] == [
+ (ti1.task_id, TaskInstanceState.SUCCESS),
+ (ti2.task_id, TaskInstanceState.SKIPPED),
+ ]
indices = session.execute(
select(TI.map_index, TI.state)
@@ -4431,6 +4447,81 @@ def
test_mapped_task_length_reduction_rerun_downstream_not_deadlocked(session, d
assert finish_ti.state == TaskInstanceState.SUCCESS
[email protected]("trigger_rule", [TriggerRule.ALL_SUCCESS,
TriggerRule.ONE_FAILED])
+def
test_mapped_task_length_reduction_marks_removed_for_downstream_in_same_pass(
+ session, dag_maker, trigger_rule
+):
+ """
+ Shrinking a mapped task mid-pass is visible to a downstream evaluated
later in the same pass.
+
+ The revise marks the surplus index REMOVED with a bulk UPDATE; the
finished-row views taken
+ at the start of the pass must reflect it, or the downstream counts a stale
FAILED upstream.
+ """
+
+ @task
+ def producer():
+ if get_current_context()["ti"].try_number == 1:
+ return [0, 1, 2]
+ return [0, 1]
+
+ @task
+ def work(arg):
+ return arg
+
+ @task(trigger_rule=trigger_rule)
+ def zreport():
+ return None
+
+ with dag_maker(session=session):
+ work.expand(arg=producer()) >> zreport()
+
+ dr = dag_maker.create_dagrun()
+ dag_maker.run_ti("producer", dr)
+ decision = dr.task_instance_scheduling_decisions(session=session)
+ assert [(ti.task_id, ti.map_index) for ti in decision.schedulable_tis] == [
+ ("work", 0),
+ ("work", 1),
+ ("work", 2),
+ ]
+ end_states = {0: TaskInstanceState.SUCCESS, 1: TaskInstanceState.SUCCESS,
2: TaskInstanceState.FAILED}
+ for ti in decision.schedulable_tis:
+ ti.set_state(end_states[ti.map_index], session=session)
+ session.flush()
+
+ # Clear work[0] together with its upstream producer, which now yields two
items.
+ clear_task_instances(
+ [
+ dr.get_task_instance("producer", session=session),
+ dr.get_task_instance("work", map_index=0, session=session),
+ ],
+ session=session,
+ )
+ session.flush()
+ dag_maker.run_ti("producer", dr)
+
+ decision = dr.task_instance_scheduling_decisions(session=session)
+
+ work_states = session.execute(
+ select(TI.map_index, TI.state)
+ .where(TI.task_id == "work", TI.dag_id == dr.dag_id, TI.run_id ==
dr.run_id)
+ .order_by(TI.map_index)
+ ).all()
+ assert work_states == [
+ (0, None),
+ (1, TaskInstanceState.SUCCESS),
+ (2, TaskInstanceState.REMOVED),
+ ]
+ # The downstream waits for work[0] instead of acting on the stale FAILED
state of work[2].
+ assert ("zreport", -1) not in [(ti.task_id, ti.map_index) for ti in
decision.schedulable_tis]
+ assert dr.get_task_instance("zreport", session=session).state is None
+
+ def _get_state_of_ti(tis, task_id, map_index):
+ return next(ti.state for ti in tis if ti.task_id == task_id and
ti.map_index == map_index)
+
+ assert _get_state_of_ti(decision.finished_tis, "work", 2) ==
TaskInstanceState.REMOVED
+ assert _get_state_of_ti(decision.tis, "work", 2) ==
TaskInstanceState.REMOVED
+
+
def test_rerun_with_upstream_task_removed(session, dag_maker):
def _task_ids(tis):
return [(ti.task_id, ti.map_index) for ti in tis]
@@ -5078,6 +5169,88 @@ def test_teardown_and_fail_fast(dag_maker):
}
+def test_scheduling_decisions_mark_orphaned_finished_tis_removed(dag_maker,
session):
+ """
+ A finished ti whose task no longer exists in the dag is marked REMOVED and
excluded.
+
+ Only the scheduled run is touched, neither another run of the Dag nor
another Dag sharing
+ the run_id and task_id loses its row.
+ """
+ with dag_maker("test_dag_1", session=session):
+ EmptyOperator(task_id="stays")
+ EmptyOperator(task_id="vanishes")
+ dag_run_1 = dag_maker.create_dagrun()
+ dag_run_1_later = dag_maker.create_dagrun_after(dag_run_1,
run_id="later_run")
+ with dag_maker("test_dag_2", session=session):
+ EmptyOperator(task_id="vanishes")
+ dag_run_2 = dag_maker.create_dagrun()
+ assert dag_run_2.run_id == dag_run_1.run_id
+ for run in (dag_run_1, dag_run_1_later, dag_run_2):
+ for ti in run.get_task_instances(session=session):
+ ti.state = TaskInstanceState.SUCCESS
+ session.flush()
+
+ del dag_run_1.dag.task_dict["vanishes"]
+ decision = dag_run_1.task_instance_scheduling_decisions(session=session)
+
+ assert len(decision.finished_tis) == 1
+ finished_ti = decision.finished_tis[0]
+ assert isinstance(finished_ti, FinishedTI)
+ assert finished_ti.task_id == "stays"
+ assert finished_ti.state == TaskInstanceState.SUCCESS
+ assert finished_ti.task is dag_run_1.dag.task_dict["stays"]
+ # The view carries the row's identity and coordinates so that trigger-rule
evaluation
+ # and the dag-callback re-fetch never need the full TaskInstance.
+ stays = dag_run_1.get_task_instance("stays", session=session)
+ assert (finished_ti.id, finished_ti.dag_id, finished_ti.run_id) == (
+ stays.id,
+ dag_run_1.dag_id,
+ dag_run_1.run_id,
+ )
+ assert (finished_ti.region_id, finished_ti.region_index) ==
(stays.region_id, stays.region_index)
+ assert finished_ti.dag_version_id == stays.dag_version_id
+ session.expire_all()
+ assert dag_run_1.get_task_instance("vanishes", session=session).state ==
TaskInstanceState.REMOVED
+ assert dag_run_1_later.get_task_instance("vanishes",
session=session).state == TaskInstanceState.SUCCESS
+ assert dag_run_2.get_task_instance("vanishes", session=session).state ==
TaskInstanceState.SUCCESS
+
+
+def
test_scheduling_decisions_orphan_removal_covers_every_map_index_but_skips_removed_rows(
+ dag_maker, session
+):
+ """Every index of an orphaned mapped task is marked REMOVED, rows already
REMOVED are not rewritten."""
+
+ @task
+ def vanishes(arg): ...
+
+ with dag_maker(session=session):
+ EmptyOperator(task_id="stays")
+ vanishes.expand(arg=[1, 2, 3])
+ dr = dag_maker.create_dagrun()
+ earlier = pendulum.datetime(2026, 1, 1, tz="UTC")
+ with time_machine.travel(earlier, tick=False):
+ for ti in dr.get_task_instances(session=session):
+ ti.state = TaskInstanceState.REMOVED if ti.map_index == 2 else
TaskInstanceState.SUCCESS
+ session.flush()
+
+ del dr.dag.task_dict["vanishes"]
+ later = earlier.add(hours=1)
+ with time_machine.travel(later, tick=False):
+ dr.task_instance_scheduling_decisions(session=session)
+
+ session.expire_all()
+ rows = session.execute(
+ select(TI.map_index, TI.state, TI.updated_at)
+ .where(TI.dag_id == dr.dag_id, TI.run_id == dr.run_id, TI.task_id ==
"vanishes")
+ .order_by(TI.map_index)
+ ).all()
+ assert rows == [
+ (0, TaskInstanceState.REMOVED, later),
+ (1, TaskInstanceState.REMOVED, later),
+ (2, TaskInstanceState.REMOVED, earlier),
+ ]
+
+
class TestDagRunHandleDagCallback:
"""Test the execute_dag_callbacks method (only uses in dag.test)."""
diff --git a/airflow-core/tests/unit/models/test_dynamic_region.py
b/airflow-core/tests/unit/models/test_dynamic_region.py
index 591cbbadd24..2255448ecd2 100644
--- a/airflow-core/tests/unit/models/test_dynamic_region.py
+++ b/airflow-core/tests/unit/models/test_dynamic_region.py
@@ -431,7 +431,7 @@ def
test_mapping_revision_only_changes_selected_expansion(dag_maker, session):
regional = TaskInstance(task, version, run_id=dr.run_id, map_index=3,
region_id=uuid4())
session.add_all([ordinary, regional])
session.flush()
- added = dr._revise_map_indexes_if_mapped(regional, session=session)
+ added, _ = dr._revise_map_indexes_if_mapped(regional, session=session)
assert [(ti.region_id, ti.region_index) for ti in added] == [
(regional.region_id, 0),
(regional.region_id, 1),
diff --git a/airflow-core/tests/unit/models/test_mappedoperator.py
b/airflow-core/tests/unit/models/test_mappedoperator.py
index 807b0aa2587..032d9699b6a 100644
--- a/airflow-core/tests/unit/models/test_mappedoperator.py
+++ b/airflow-core/tests/unit/models/test_mappedoperator.py
@@ -416,7 +416,7 @@ def
test_stable_mapped_indexes_do_not_query_historical_max_try(dag_maker, sessio
live = run.get_task_instance(mapped.task_id, map_index=0, session=session)
live.task = dag.task_dict[mapped.task_id]
try:
- new_tis = run._revise_map_indexes_if_mapped(live, session=session)
+ new_tis, _ = run._revise_map_indexes_if_mapped(live, session=session)
finally:
event.remove(session.bind, "before_cursor_execute", capture_sql)
@@ -437,7 +437,7 @@ def
test_missing_mapped_index_uses_retained_max_try(dag_maker, session):
live = run.get_task_instance(mapped.task_id, map_index=0, session=session)
live.task = dag.task_dict[mapped.task_id]
- new_tis = run._revise_map_indexes_if_mapped(live, session=session)
+ new_tis, _ = run._revise_map_indexes_if_mapped(live, session=session)
assert len(new_tis) == 1
assert new_tis[0].map_index == 1
@@ -2280,10 +2280,11 @@ def
test_expansion_grows_and_shrinks_within_one_region(mapped_dag, session):
push_mapped_length(dr.get_task_instance(upstream_id, session=session),
list(range(5)), session=session)
first[0].task = serialized
- assert [ti.region_index for ti in
dr._revise_map_indexes_if_mapped(first[0], session=session)] == [3, 4]
+ new_tis, removed = dr._revise_map_indexes_if_mapped(first[0],
session=session)
+ assert ([ti.region_index for ti in new_tis], removed) == ([3, 4], set())
push_mapped_length(dr.get_task_instance(upstream_id, session=session),
list(range(2)), session=session)
- assert dr._revise_map_indexes_if_mapped(first[0], session=session) == []
+ assert dr._revise_map_indexes_if_mapped(first[0], session=session) == ([],
{2, 3, 4})
session.expire_all()
states = {ti.region_index: ti.state for ti in _live_tis(session, dr,
serialized.task_id)}
assert states[0] is None
diff --git a/airflow-core/tests/unit/models/test_taskinstance.py
b/airflow-core/tests/unit/models/test_taskinstance.py
index 286a04574db..f5b8116038c 100644
--- a/airflow-core/tests/unit/models/test_taskinstance.py
+++ b/airflow-core/tests/unit/models/test_taskinstance.py
@@ -4105,11 +4105,9 @@ class TestMappedTaskInstanceReceiveValue:
session.flush()
with assert_queries_count(5, margin=1):
- new_tis = list(
- dag_run._revise_map_indexes_if_mapped(
- mapped_tis[0],
- session=session,
- )
+ new_tis, _ = dag_run._revise_map_indexes_if_mapped(
+ mapped_tis[0],
+ session=session,
)
# Only the two brand-new indexes (3, 4) are created, contiguous and
usable.
assert sorted(ti.map_index for ti in new_tis) == [3, 4]