This is an automated email from the ASF dual-hosted git repository.
shahar1 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 ce01a733e93 Fix scheduler memory spike when a Dag run has a large conf
(#73907)
ce01a733e93 is described below
commit ce01a733e93f3262ef6f19f3d82632da72175925
Author: Lucas Pimentel <[email protected]>
AuthorDate: Sun Nov 1 16:39:36 2026 -0300
Fix scheduler memory spike when a Dag run has a large conf (#73907)
* Add tests for the run conf loaded per task instance when scheduling
TaskInstance.dag_run is a joined eager load, so a task-instance query
that also selects dag_run.conf returns and decodes one copy of the conf
per row. A scheduling pass does that twice: fetch_task_instances reads
every task instance of the run, and _get_ready_tis re-reads the ones
still waiting on their upstreams. A large conf on a run with many
mapped task instances multiplies scheduler memory this way.
* Fix scheduler memory spike when a Dag run has a large conf
TaskInstance.dag_run is a joined eager load, so every row of a
task-instance query also carries the run's conf. The scheduler fetches
all task instances of each running Dag run on every loop, and re-reads
the ones still waiting on their upstreams, so a large conf was decoded
once per task instance: a 1 MB conf on a run with 500 mapped task
instances took the scheduler from 150 MB to over 3 GB.
Neither query needs the conf up front: the few callers that read it
can load it on access, and the re-check only compares states.
---
airflow-core/src/airflow/models/dagrun.py | 10 ++++--
airflow-core/tests/unit/models/test_dagrun.py | 51 ++++++++++++++++++++++++++-
2 files changed, 58 insertions(+), 3 deletions(-)
diff --git a/airflow-core/src/airflow/models/dagrun.py
b/airflow-core/src/airflow/models/dagrun.py
index 6a1ea5eb6f5..6b8236805f1 100644
--- a/airflow-core/src/airflow/models/dagrun.py
+++ b/airflow-core/src/airflow/models/dagrun.py
@@ -63,6 +63,7 @@ from sqlalchemy.orm import (
Mapped,
declared_attr,
joinedload,
+ lazyload,
mapped_column,
relationship,
synonym,
@@ -1064,8 +1065,10 @@ class DagRun(Base, LoggingMixin):
If ``columns`` is provided then the result is plain rows with the
values of these columns, otherwise the result is ORM objects.
"""
+ # The joined run comes back on every row. Its conf can be large, so it
loads on first
+ # access instead of being decoded once per task instance.
stmt = (
- (select(*columns) if columns else
select(TI).options(joinedload(TI.dag_run)))
+ (select(*columns) if columns else
select(TI).options(joinedload(TI.dag_run).defer(DagRun.conf)))
.where(TI.dag_id == dag_id, TI.run_id == run_id)
.order_by(TI.task_id, TI.region_id, TI.region_index)
)
@@ -1975,7 +1978,10 @@ class DagRun(Base, LoggingMixin):
# Check if any ti changed state
if old_states:
- fresh_tis =
session.scalars(select(TI).where(TI.id.in_(old_states))).all()
+ # Joining the run here would decode its conf once per row; only
the states are needed.
+ fresh_tis = session.scalars(
+
select(TI).options(lazyload(TI.dag_run)).where(TI.id.in_(old_states))
+ ).all()
changed_tis = any(ti.state != old_states[ti.id] for ti in
fresh_tis)
return ready_tis, changed_tis, expansion_happened
diff --git a/airflow-core/tests/unit/models/test_dagrun.py
b/airflow-core/tests/unit/models/test_dagrun.py
index 7097d93da05..cf162e0487d 100644
--- a/airflow-core/tests/unit/models/test_dagrun.py
+++ b/airflow-core/tests/unit/models/test_dagrun.py
@@ -18,6 +18,7 @@
from __future__ import annotations
import datetime
+import re
from collections import defaultdict
from collections.abc import Mapping
from concurrent.futures import ThreadPoolExecutor
@@ -116,7 +117,7 @@ from airflow.utils.state import DagRunState,
DagSchedulingState, State, TaskInst
from airflow.utils.types import DagRunTriggeredByType, DagRunType
from tests_common.test_utils import db
-from tests_common.test_utils.asserts import assert_queries_count
+from tests_common.test_utils.asserts import assert_queries_count,
capture_orm_selects
from tests_common.test_utils.config import conf_vars
from tests_common.test_utils.dag import sync_dag_to_db
from tests_common.test_utils.db import clear_db_dags, clear_db_runs,
clear_db_serialized_dags, clear_db_xcom
@@ -134,6 +135,8 @@ pytestmark = [pytest.mark.db_test,
pytest.mark.need_serialized_dag]
TI = TaskInstance
DEFAULT_DATE = pendulum.instance(_DEFAULT_DATE)
+# A joined eager load of TaskInstance.dag_run aliases the table, hence the
optional suffix.
+SELECTS_RUN_CONF = re.compile(r"\bdag_run(?:_\d+)?\.conf\b")
def get_callback_ti(ti):
@@ -1132,6 +1135,52 @@ class TestDagRun:
ti = dag_run.get_task_instance("test_short_circuit_false")
assert ti is None
+ def test_fetch_task_instances_does_not_select_the_run_conf(self,
dag_maker, session):
+ """
+ The run's conf must not come back on every task-instance row.
+
+ ``TaskInstance.dag_run`` is a joined eager load, so a query that
selected ``dag_run.conf``
+ would return and decode one copy of the conf per task instance.
+ """
+ conf = {"payload": "x" * 1024}
+ with dag_maker(session=session):
+ EmptyOperator(task_id="t1")
+ EmptyOperator(task_id="t2")
+ dag_run = dag_maker.create_dagrun(conf=conf)
+ dag_id, run_id = dag_run.dag_id, dag_run.run_id
+ session.expunge_all()
+
+ with capture_orm_selects("task_instance") as statements:
+ tis = DagRun.fetch_task_instances(dag_id=dag_id, run_id=run_id,
session=session)
+
+ assert [ti.task_id for ti in tis] == ["t1", "t2"]
+ assert statements
+ assert not any(SELECTS_RUN_CONF.search(sql) for sql in statements),
statements
+ # The run is still joined, and its conf loads when read.
+ assert tis[0].dag_run.run_id == run_id
+ assert tis[0].dag_run.conf == conf
+
+ def test_scheduling_decisions_do_not_select_the_run_conf(self, dag_maker,
session):
+ """
+ A scheduling pass must not load the run's conf once per task instance.
+
+ ``downstream`` waits for ``upstream``, so besides fetching the run's
task instances the pass
+ re-reads the waiting ones to see whether their state changed.
+ """
+ with dag_maker(session=session):
+ EmptyOperator(task_id="upstream") >>
EmptyOperator(task_id="downstream")
+ dag_run = dag_maker.create_dagrun(conf={"payload": "x" * 1024})
+
+ with capture_orm_selects("task_instance") as statements:
+ decision =
dag_run.task_instance_scheduling_decisions(session=session)
+
+ assert [ti.task_id for ti in decision.schedulable_tis] == ["upstream"]
+ assert statements
+ selecting_conf = [sql for sql in statements if
SELECTS_RUN_CONF.search(sql)]
+ assert not selecting_conf, (
+ f"{len(selecting_conf)} of {len(statements)} task-instance queries
select the run conf"
+ )
+
def test_get_latest_runs(self, dag_maker, session):
with dag_maker(
dag_id="test_latest_runs_1", schedule=datetime.timedelta(days=1),
start_date=DEFAULT_DATE