This is an automated email from the ASF dual-hosted git repository.
o-nikolas 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 fb69e2b4a32 Pin task bundle manifest to the dagrun's version (#69941)
fb69e2b4a32 is described below
commit fb69e2b4a322f790504f049622b8cd9eb316bdcf
Author: Niko Oliveira <[email protected]>
AuthorDate: Fri Jul 17 12:35:30 2026 -0700
Pin task bundle manifest to the dagrun's version (#69941)
A mid-run DAG re-parse possibly creates a new DagVersion and bumps
not-yet-started task instances' dag_version_id, while the DagRun
stays pinned to its original bundle_version. ExecuteTask.make()
sourced the bundle version from the run's pin (dag_run.bundle_version)
but bundle version_data from the task's now-bumped dag_version,
so the workload shipped a hash and a manifest that describe
different versions. This double source was noticed for the callback
path in a previous PR, but the task path was missed.
Source version_data from the run's pinned created_dag_version so it always
matches the bundle version, mirroring ExecuteCallback.make() and the way Git
bundles resolve everything from the run's pinned commit.
---
.../src/airflow/executors/workloads/task.py | 6 ++-
.../src/airflow/jobs/scheduler_job_runner.py | 17 ++++---
.../tests/unit/executors/test_workloads.py | 55 +++++++++++++++++-----
airflow-core/tests/unit/jobs/test_scheduler_job.py | 37 ++++++++++++++-
.../aws/executors/batch/test_batch_executor.py | 2 +
.../amazon/aws/executors/ecs/test_ecs_executor.py | 2 +
6 files changed, 99 insertions(+), 20 deletions(-)
diff --git a/airflow-core/src/airflow/executors/workloads/task.py
b/airflow-core/src/airflow/executors/workloads/task.py
index 68a917118e3..3099fe1d774 100644
--- a/airflow-core/src/airflow/executors/workloads/task.py
+++ b/airflow-core/src/airflow/executors/workloads/task.py
@@ -107,7 +107,11 @@ class ExecuteTask(BaseDagBundleWorkload):
bundle_info = BundleInfo(
name=ti.dag_model.bundle_name,
version=ti.dag_run.bundle_version,
- version_data=_resolve_version_data(ti.dag_version,
ti.dag_run.bundle_version),
+ # Source version_data from the run's pinned version (matching
``version`` above),
+ # not the TI's dag_version. A mid-run DAG re-parse can bump
the TI's dag_version
+ # to a newer version while the run stays pinned; sourcing from
created_dag_version
+ # keeps the shipped hash and manifest consistent so versioned
bundles stay reproducible.
+
version_data=_resolve_version_data(ti.dag_run.created_dag_version,
ti.dag_run.bundle_version),
)
fname = log_filename_template_renderer()(ti=ti)
diff --git a/airflow-core/src/airflow/jobs/scheduler_job_runner.py
b/airflow-core/src/airflow/jobs/scheduler_job_runner.py
index f6d6a4787f6..c2f3ee05d73 100644
--- a/airflow-core/src/airflow/jobs/scheduler_job_runner.py
+++ b/airflow-core/src/airflow/jobs/scheduler_job_runner.py
@@ -684,12 +684,17 @@ class SchedulerJobRunner(BaseJobRunner, LoggingMixin):
ranked_query.c.map_index_for_ordering,
)
.options(selectinload(TI.dag_model))
- # Eager-load dag_version: TIs become transient (via
make_transient) before
- # ExecuteTask.make() reads ti.dag_version.version_data. Lazy
loads on
- # transient objects silently return None instead of raising
DetachedInstanceError.
- # Scope the second SELECT to version_data (the PK is
auto-included) so we read
- # two columns rather than the full DagVersion row.
-
.options(selectinload(TI.dag_version).load_only(DagVersion.version_data))
+ # Eager-load the run's pinned DagVersion
(dag_run.created_dag_version): TIs become
+ # transient (via make_transient) before ExecuteTask.make()
reads
+ # ti.dag_run.created_dag_version.version_data to ship the
bundle manifest matching
+ # the run's pinned bundle_version. Lazy loads on transient
objects silently return
+ # None instead of raising DetachedInstanceError. Scope the
SELECT to version_data
+ # (the PK is auto-included) so we read two columns rather than
the full row.
+ .options(
+ joinedload(TI.dag_run)
+ .selectinload(DagRun.created_dag_version)
+ .load_only(DagVersion.version_data)
+ )
)
query = query.limit(max_tis)
diff --git a/airflow-core/tests/unit/executors/test_workloads.py
b/airflow-core/tests/unit/executors/test_workloads.py
index 1ef04477d08..37fbcd96ce9 100644
--- a/airflow-core/tests/unit/executors/test_workloads.py
+++ b/airflow-core/tests/unit/executors/test_workloads.py
@@ -184,12 +184,21 @@ class TestExecuteTaskMakeVersionData:
)
@staticmethod
- def _make_mock_ti(bundle_version, version_data, *, has_dag_version=True):
+ def _make_mock_ti(
+ bundle_version,
+ version_data,
+ *,
+ has_created_dag_version=True,
+ ti_dag_version_data=None,
+ ):
"""Build a mock TI with the attributes ExecuteTask.make() reads.
- ``has_dag_version`` controls whether the TI has an associated
DagVersion
- (legacy/backfilled TIs may not), independently of ``version_data`` so
the
- pin-guard can be exercised with version_data present on an unpinned
run.
+ ``version_data`` is the manifest on the run's pinned version
+ (``dag_run.created_dag_version``) -- the source make() must use.
+ ``ti_dag_version_data`` is the manifest on ``ti.dag_version``, which
make()
+ must IGNORE (it can diverge from the run's pin after a mid-run DAG
re-parse).
+ ``has_created_dag_version`` toggles whether the run has a pinned
DagVersion
+ (legacy/backfilled runs may not).
"""
from unittest.mock import Mock
@@ -215,15 +224,18 @@ class TestExecuteTaskMakeVersionData:
ti.dag_run.bundle_version = bundle_version
- if has_dag_version:
- ti.dag_version.version_data = version_data
+ # make() must source version_data from the run's pinned version, never
ti.dag_version.
+ ti.dag_version.version_data = ti_dag_version_data
+
+ if has_created_dag_version:
+ ti.dag_run.created_dag_version.version_data = version_data
else:
- ti.dag_version = None
+ ti.dag_run.created_dag_version = None
return ti
def test_pinned_run_populates_version_data(self):
- """When the run is pinned, version_data from dag_version flows to
BundleInfo."""
+ """When the run is pinned, version_data from the run's
created_dag_version flows to BundleInfo."""
version_data = {"schema_version": 1, "files": {"dags/my_dag.py":
"ver123"}}
ti = self._make_mock_ti(bundle_version="abc123",
version_data=version_data)
@@ -233,7 +245,7 @@ class TestExecuteTaskMakeVersionData:
assert workload.bundle_info.version_data == version_data
def test_unpinned_run_suppresses_present_version_data(self):
- """An unpinned run must not expose version_data even when the
dag_version carries it."""
+ """An unpinned run must not expose version_data even when
created_dag_version carries it."""
version_data = {"schema_version": 1, "files": {"dags/my_dag.py":
"ver123"}}
ti = self._make_mock_ti(bundle_version=None, version_data=version_data)
@@ -242,15 +254,34 @@ class TestExecuteTaskMakeVersionData:
assert workload.bundle_info.version is None
assert workload.bundle_info.version_data is None
- def test_missing_dag_version_yields_none(self):
- """A pinned run whose TI has no dag_version (legacy/backfilled) yields
no version_data."""
- ti = self._make_mock_ti(bundle_version="abc123", version_data=None,
has_dag_version=False)
+ def test_missing_created_dag_version_yields_none(self):
+ """A pinned run whose DagRun has no created_dag_version yields no
version_data."""
+ ti = self._make_mock_ti(bundle_version="abc123", version_data=None,
has_created_dag_version=False)
workload = ExecuteTask.make(ti)
assert workload.bundle_info.version == "abc123"
assert workload.bundle_info.version_data is None
+ def test_mid_run_dag_version_bump_uses_run_pinned_manifest(self):
+ """Regression: a mid-run DAG re-parse can bump ti.dag_version to a
newer version while the
+ run stays pinned. make() must ship the run's pinned manifest
(created_dag_version), not the
+ TI's bumped one -- otherwise a versioned bundle would fetch the wrong
(latest) code.
+ """
+ run_manifest = {"schema_version": 1, "files": {"dags/my_dag.py":
"v1-object-id"}}
+ bumped_manifest = {"schema_version": 1, "files": {"dags/my_dag.py":
"v2-object-id"}}
+ ti = self._make_mock_ti(
+ bundle_version="v1hash",
+ version_data=run_manifest,
+ ti_dag_version_data=bumped_manifest,
+ )
+
+ workload = ExecuteTask.make(ti)
+
+ assert workload.bundle_info.version == "v1hash"
+ assert workload.bundle_info.version_data == run_manifest
+ assert workload.bundle_info.version_data != bumped_manifest
+
class TestExecuteCallbackMakeVersionData:
"""Tests for ExecuteCallback.make() threading version_data through
BundleInfo."""
diff --git a/airflow-core/tests/unit/jobs/test_scheduler_job.py
b/airflow-core/tests/unit/jobs/test_scheduler_job.py
index 1f3b6992c3e..68f4ec09150 100644
--- a/airflow-core/tests/unit/jobs/test_scheduler_job.py
+++ b/airflow-core/tests/unit/jobs/test_scheduler_job.py
@@ -142,7 +142,7 @@ from airflow.utils.state import CallbackState, DagRunState,
State, TaskInstanceS
from airflow.utils.types import DagRunTriggeredByType, DagRunType
from tests_common.pytest_plugin import AIRFLOW_ROOT_PATH
-from tests_common.test_utils.asserts import assert_queries_count
+from tests_common.test_utils.asserts import assert_queries_count, count_queries
from tests_common.test_utils.config import conf_vars, env_vars
from tests_common.test_utils.dag import create_scheduler_dag, sync_dag_to_db,
sync_dags_to_db
from tests_common.test_utils.db import (
@@ -1425,6 +1425,41 @@ class TestSchedulerJob:
assert {x.key for x in queued_tis} == {ti_non_backfill.key,
ti_backfill.key}
session.rollback()
+ def test_executable_task_instances_no_per_ti_queries(self, dag_maker,
session):
+ """Guard against an N+1 when enqueuing task instances.
+
+ ``ExecuteTask.make()`` reads
``ti.dag_run.created_dag_version.version_data`` to ship the
+ run's pinned bundle manifest. ``dag_run`` is eager-joined and
``created_dag_version`` is a
+ single batched ``selectin``, so the number of queries in
+ ``_executable_task_instances_to_queued`` must be independent of how
many task instances are
+ in the batch. If a future change lazy-loads
``dag_run``/``created_dag_version`` per TI, the
+ count would scale with the task count and this test fails.
+ """
+ scheduler_job = Job()
+ runner = SchedulerJobRunner(job=scheduler_job)
+ self.job_runner = runner
+
+ def _measure(dag_id: str, num_tasks: int) -> int:
+ with dag_maker(dag_id=dag_id, max_active_tasks=64,
session=session):
+ for i in range(num_tasks):
+ EmptyOperator(task_id=f"t{i}")
+ dr = dag_maker.create_dagrun(run_type=DagRunType.SCHEDULED)
+ for ti in dr.task_instances:
+ ti.state = State.SCHEDULED
+ session.flush()
+ with count_queries(session=session) as result:
+ runner._executable_task_instances_to_queued(max_tis=64,
session=session)
+ session.rollback()
+ return sum(result.values())
+
+ one_task = _measure("q_count_one", 1)
+ many_tasks = _measure("q_count_many", 10)
+
+ assert one_task == many_tasks, (
+ f"query count scaled with task-instance count ({one_task} ->
{many_tasks}); "
+ "likely a per-TI lazy load (N+1) of dag_run/created_dag_version"
+ )
+
def
test_find_executable_task_instances_mysql_hint_only_applies_to_inner_query(self,
dag_maker, session):
dag_id =
"SchedulerJobTest.test_find_executable_task_instances_mysql_hint_only_applies_to_inner_query"
task_id = "dummy"
diff --git
a/providers/amazon/tests/unit/amazon/aws/executors/batch/test_batch_executor.py
b/providers/amazon/tests/unit/amazon/aws/executors/batch/test_batch_executor.py
index f5800232610..4bfd7c716ef 100644
---
a/providers/amazon/tests/unit/amazon/aws/executors/batch/test_batch_executor.py
+++
b/providers/amazon/tests/unit/amazon/aws/executors/batch/test_batch_executor.py
@@ -817,6 +817,8 @@ class TestAwsBatchExecutor:
task.dag_version = mock.Mock(version_data=None)
task.dag_run = mock.Mock()
task.dag_run.bundle_version = "1.0.0"
+ # ExecuteTask.make() sources version_data from the run's pinned
version.
+ task.dag_run.created_dag_version = mock.Mock(version_data=None)
task.dag_run.context_carrier = {}
if not AIRFLOW_V_3_0_PLUS:
diff --git
a/providers/amazon/tests/unit/amazon/aws/executors/ecs/test_ecs_executor.py
b/providers/amazon/tests/unit/amazon/aws/executors/ecs/test_ecs_executor.py
index b7c25d51277..b6074b8a554 100644
--- a/providers/amazon/tests/unit/amazon/aws/executors/ecs/test_ecs_executor.py
+++ b/providers/amazon/tests/unit/amazon/aws/executors/ecs/test_ecs_executor.py
@@ -1312,6 +1312,8 @@ class TestAwsEcsExecutor:
task.dag_version = mock.Mock(version_data=None)
task.dag_run = mock.Mock()
task.dag_run.bundle_version = "1.0.0"
+ # ExecuteTask.make() sources version_data from the run's pinned
version.
+ task.dag_run.created_dag_version = mock.Mock(version_data=None)
task.dag_run.context_carrier = {}
# Mock command generation based on Airflow version