This is an automated email from the ASF dual-hosted git repository.
pierrejeambrun 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 1f70ead81ef Fix /ui/dags recent-runs query degradation on large,
skewed dag_run tables (#74401)
1f70ead81ef is described below
commit 1f70ead81ef00ac8e0ddb338a862d4f0f226a78b
Author: Ivan Tham <[email protected]>
AuthorDate: Wed Oct 14 19:30:22 2026 +0800
Fix /ui/dags recent-runs query degradation on large, skewed dag_run tables
(#74401)
* Fix /ui/dags recent-runs query degradation on large, skewed dag_run tables
The per-Dag UNION ALL branches introduced in #67721 plan as backward index
scans of the global idx_dag_run_run_after with a Filter on dag_id. On a
large, skewed dag_run table this is catastrophic for "cold" Dags — those
whose last run is months old or that never ran: the scan must walk
essentially every row of every *other* Dag before reaching that branch's
rows (measured on a 3.8M-row metadata DB: 3,800,261 rows removed by filter,
~2.3s for a single cold branch, 30-60s per page — gateway 504s), because
at default statistics the planner misestimates cold dag_ids (they fall
outside the MCV list) and bets the backward scan hits LIMIT matches
quickly. Hot pages are affected too, bistably: the identical statement
was observed flipping 22.8ms <-> 141s across a single ANALYZE.
Replace the 50-branch union with one page-scoped window query:
row_number() OVER (PARTITION BY dag_id ORDER BY run_after DESC, id DESC)
over WHERE dag_id IN (page's dag ids), filtered to rn <= dag_runs_limit.
This restores the pre-#67721 access path (per-Dag probes of
idx_dag_run_dag_id + one sort + WindowAgg), bounded regardless of where
each Dag's runs sit in time or what the planner estimates, while keeping
#67721's exact-N-per-Dag semantics. On run_after ties the window's
(id DESC) tiebreak deterministically keeps the latest-inserted row,
where the old per-branch LIMIT picked arbitrarily; the outer ORDER BY
leaves tied rows in unspecified order, same as the union. All columns
are selected inline (no join-back). dag_runs_limit is now NonNegativeInt,
matching limit/offset.
Measured end-to-end on a dockerized copy of a 3.8M-row production
metadata DB (PostgreSQL 15.14, default statistics, 74%-cold first page):
GET /ui/dags?limit=50&dag_runs_limit=14: 31.2s -> 0.13-0.64s
same rows and order returned (291/291 id-set match, total_entries
unchanged); selection can differ from the union only on run_after ties,
where the new pick is the newer run
Trade-off: the window sorts all runs of the page's Dags, so a page of the
50 highest-run-count Dags (~619K rows) costs ~2.0s with a 58MB external
sort (vs the union's 0.25-12s bistable plan) — linear and deterministic
instead of the 30-60s+ cold-page catastrophe.
The window-function shape is portable (same ANSI syntax as the
pre-#67721 rank() query and the existing row_number usage in
common/db/assets.py), so the sqlite/mysql/mssql matrix is unaffected.
Co-Authored-By: Claude Code <[email protected]>
* Add tests for recent-runs limit and run_after tie-breaking
Per review: one test asserts a Dag with more runs than dag_runs_limit
returns exactly the N newest in descending run_after order; one test
pins the (run_after DESC, id DESC) tie-break — with two runs tied on
run_after, the later-inserted run (higher id) is the deterministic pick,
which is the one spot the window query's output differs from the old
per-branch union.
Co-Authored-By: Claude Code <[email protected]>
* Remove newsfragment per review
Co-Authored-By: Claude Code <[email protected]>
* Trim redundant comments in the recent-runs tests
Co-Authored-By: Claude Code <[email protected]>
* Keep dag_runs_limit as plain int
The NonNegativeInt validation is a contract change unrelated to the
regression this PR fixes — out of scope here.
Co-Authored-By: Claude Code <[email protected]>
* Drop the explanatory comment above the ranked query
Co-Authored-By: Claude Code <[email protected]>
---------
Co-authored-by: Claude Code <[email protected]>
---
.../airflow/api_fastapi/core_api/routes/ui/dags.py | 31 +++++++++----
.../api_fastapi/core_api/routes/ui/test_dags.py | 51 ++++++++++++++++++++++
2 files changed, 74 insertions(+), 8 deletions(-)
diff --git a/airflow-core/src/airflow/api_fastapi/core_api/routes/ui/dags.py
b/airflow-core/src/airflow/api_fastapi/core_api/routes/ui/dags.py
index 6888ed886a5..23886e8840b 100644
--- a/airflow-core/src/airflow/api_fastapi/core_api/routes/ui/dags.py
+++ b/airflow-core/src/airflow/api_fastapi/core_api/routes/ui/dags.py
@@ -233,7 +233,7 @@ def get_dags(
recent_dag_runs: list = []
if dags:
- recent_runs_branches = [
+ ranked = (
select(
DagRun.id,
DagRun.dag_id,
@@ -243,16 +243,31 @@ def get_dags(
DagRun.run_after,
DagRun.start_date,
DagRun.state,
+ func.row_number()
+ .over(
+ partition_by=DagRun.dag_id,
+ order_by=(DagRun.run_after.desc(), DagRun.id.desc()),
+ )
+ .label("rn"),
)
- .where(DagRun.dag_id == dag.dag_id)
- .order_by(DagRun.run_after.desc())
- .limit(dag_runs_limit)
+ .where(DagRun.dag_id.in_([dag.dag_id for dag in dags]))
.subquery()
- for dag in dags
- ]
- recent_runs_union = union_all(*(select(branch) for branch in
recent_runs_branches)).subquery()
+ )
recent_dag_runs = list(
-
session.execute(select(recent_runs_union).order_by(recent_runs_union.c.run_after.desc()))
+ session.execute(
+ select(
+ ranked.c.id,
+ ranked.c.dag_id,
+ ranked.c.run_id,
+ ranked.c.end_date,
+ ranked.c.logical_date,
+ ranked.c.run_after,
+ ranked.c.start_date,
+ ranked.c.state,
+ )
+ .where(ranked.c.rn <= dag_runs_limit)
+ .order_by(ranked.c.run_after.desc())
+ )
)
# Fetch pending HITL actions for each Dag if we are not certain whether
some of the Dag might contain HITL actions
diff --git
a/airflow-core/tests/unit/api_fastapi/core_api/routes/ui/test_dags.py
b/airflow-core/tests/unit/api_fastapi/core_api/routes/ui/test_dags.py
index ed9a5f36ae0..dcee4b1a86b 100644
--- a/airflow-core/tests/unit/api_fastapi/core_api/routes/ui/test_dags.py
+++ b/airflow-core/tests/unit/api_fastapi/core_api/routes/ui/test_dags.py
@@ -207,6 +207,57 @@ class TestGetDagRuns(TestPublicDagEndpoint):
assert dags_by_id[DAG1_ID]["has_unfinished_runs"] is True
assert dags_by_id[DAG2_ID]["has_unfinished_runs"] is False
+ @pytest.mark.usefixtures("configure_git_connection_for_dag_bundle")
+ def test_recent_dag_runs_returns_exactly_n_newest_in_order(self,
test_client, session):
+ """A Dag with more runs than dag_runs_limit returns exactly the N
newest, newest first."""
+ response = test_client.get(
+ "/dags",
+ params={"dag_ids": [DAG1_ID], "dag_runs_limit": 3},
+ )
+
+ assert response.status_code == 200
+ dag_runs = response.json()["dags"][0]["latest_dag_runs"]
+ assert [run["run_id"] for run in dag_runs] == ["run_id_5", "run_id_4",
"run_id_3"]
+ run_afters = [run["run_after"] for run in dag_runs]
+ assert run_afters == sorted(run_afters, reverse=True)
+
+ @pytest.mark.usefixtures("configure_git_connection_for_dag_bundle")
+ def test_recent_dag_runs_tie_breaks_on_id_desc(self, test_client, session):
+ """Two runs tied on run_after: the later-inserted one (higher id)
wins, deterministically."""
+ tie_run_after = pendulum.datetime(2030, 6, 1, tz="UTC")
+ session.add_all(
+ [
+ DagRun(
+ dag_id=DAG1_ID,
+ run_id="tie_first_inserted",
+ run_type=DagRunType.MANUAL,
+ logical_date=tie_run_after - pendulum.duration(days=1),
+ run_after=tie_run_after,
+ state=DagRunState.SUCCESS,
+ triggered_by=DagRunTriggeredByType.TEST,
+ ),
+ DagRun(
+ dag_id=DAG1_ID,
+ run_id="tie_second_inserted",
+ run_type=DagRunType.MANUAL,
+ logical_date=tie_run_after - pendulum.duration(days=2),
+ run_after=tie_run_after,
+ state=DagRunState.SUCCESS,
+ triggered_by=DagRunTriggeredByType.TEST,
+ ),
+ ]
+ )
+ session.commit()
+
+ response = test_client.get(
+ "/dags",
+ params={"dag_ids": [DAG1_ID], "dag_runs_limit": 1},
+ )
+
+ assert response.status_code == 200
+ dag_runs = response.json()["dags"][0]["latest_dag_runs"]
+ assert dag_runs[0]["run_id"] == "tie_second_inserted"
+
@pytest.mark.usefixtures("configure_git_connection_for_dag_bundle")
@pytest.mark.parametrize(
("query_params", "expected_ids"),