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 ff839573e94 Bound remaining single-row lookups with .limit(1) (#72699)
ff839573e94 is described below

commit ff839573e94f0c564680b15d1ff1bc87d0200728
Author: Pierre Jeambrun <[email protected]>
AuthorDate: Wed Sep 9 18:21:30 2026 +0200

    Bound remaining single-row lookups with .limit(1) (#72699)
    
    * Bound remaining single-row lookups with .limit(1)
    
    Two more callers of ``session.scalars(...).first()`` /
    ``session.execute(select(...)).first()`` were still missing an explicit
    ``.limit(1)``. Because SQLAlchemy 2.0's ``Result.first()`` does not add
    one (unlike the legacy ``Query.first``), each call sends every matching
    row to the Python process and discards all but the first. The favorite
    existence check is bounded to a single row per (dag_id, user_id) by the
    schema, but the previous-TI lookup scans every earlier matching TI.
    
    Introduced independently of #52325's migration: get_previous_task_instance
    came in #59712, unfavorite_dag's existence probe in #51264. Same
    anti-pattern as the four sites #72554 fixes; the tests reuse the
    ``capture_orm_selects`` helper that PR introduces.
    
    * Also assert the returned row on the previous-TI regression test
---
 .../api_fastapi/core_api/routes/public/dags.py     |  4 +++-
 .../execution_api/routes/task_instances.py         |  2 +-
 .../core_api/routes/public/test_dags.py            | 18 +++++++++++++++-
 .../versions/head/test_task_instances.py           | 25 ++++++++++++++++++++++
 4 files changed, 46 insertions(+), 3 deletions(-)

diff --git 
a/airflow-core/src/airflow/api_fastapi/core_api/routes/public/dags.py 
b/airflow-core/src/airflow/api_fastapi/core_api/routes/public/dags.py
index fd1516b99ef..2140707ab2d 100644
--- a/airflow-core/src/airflow/api_fastapi/core_api/routes/public/dags.py
+++ b/airflow-core/src/airflow/api_fastapi/core_api/routes/public/dags.py
@@ -436,10 +436,12 @@ def unfavorite_dag(dag_id: str, session: SessionDep, 
user: GetUserDep):
     user_id = str(user.get_id())
 
     favorite_exists = session.execute(
-        select(DagFavorite).where(
+        select(DagFavorite)
+        .where(
             DagFavorite.dag_id == dag_id,
             DagFavorite.user_id == user_id,
         )
+        .limit(1)
     ).first()
 
     if not favorite_exists:
diff --git 
a/airflow-core/src/airflow/api_fastapi/execution_api/routes/task_instances.py 
b/airflow-core/src/airflow/api_fastapi/execution_api/routes/task_instances.py
index 625e2ee9116..bc00ccf07c9 100644
--- 
a/airflow-core/src/airflow/api_fastapi/execution_api/routes/task_instances.py
+++ 
b/airflow-core/src/airflow/api_fastapi/execution_api/routes/task_instances.py
@@ -1225,7 +1225,7 @@ def get_previous_task_instance(
     if state:
         query = query.where(TI.state == state)
 
-    ti = session.scalars(query).first()
+    ti = session.scalars(query.limit(1)).first()
 
     if not ti:
         return None
diff --git 
a/airflow-core/tests/unit/api_fastapi/core_api/routes/public/test_dags.py 
b/airflow-core/tests/unit/api_fastapi/core_api/routes/public/test_dags.py
index c376d43f759..b37ccf1a130 100644
--- a/airflow-core/tests/unit/api_fastapi/core_api/routes/public/test_dags.py
+++ b/airflow-core/tests/unit/api_fastapi/core_api/routes/public/test_dags.py
@@ -16,6 +16,7 @@
 # under the License.
 from __future__ import annotations
 
+import re
 from datetime import datetime, timedelta, timezone
 from unittest import mock
 
@@ -33,7 +34,7 @@ from airflow.providers.standard.operators.empty import 
EmptyOperator
 from airflow.utils.state import DagRunState, TaskInstanceState
 from airflow.utils.types import DagRunTriggeredByType, DagRunType
 
-from tests_common.test_utils.asserts import assert_queries_count, count_queries
+from tests_common.test_utils.asserts import assert_queries_count, 
capture_orm_selects, count_queries
 from tests_common.test_utils.config import conf_vars
 from tests_common.test_utils.db import (
     clear_db_assets,
@@ -1185,6 +1186,21 @@ class TestUnfavoriteDag(TestDagEndpoint):
         response = test_client.post(f"/dags/{DAG1_ID}/unfavorite")
         assert response.status_code == 409
 
+    def test_unfavorite_dag_existence_check_is_bounded(self, test_client, 
session):
+        """The existing-favorite existence probe must ask the DB for one 
row."""
+        session.execute(insert(DagFavorite).values(dag_id=DAG1_ID, 
user_id="test"))
+        session.commit()
+
+        with capture_orm_selects("dag_favorite") as statements:
+            response = test_client.post(f"/dags/{DAG1_ID}/unfavorite")
+
+        assert response.status_code == 204
+        assert statements, "expected the endpoint to query the dag_favorite 
table"
+        for sql in statements:
+            assert re.search(r"\bLIMIT 1\b", sql), (
+                f"favorite existence check is not bounded to one row: {sql}"
+            )
+
 
 class TestDagDetails(TestDagEndpoint):
     """Unit tests for DAG Details."""
diff --git 
a/airflow-core/tests/unit/api_fastapi/execution_api/versions/head/test_task_instances.py
 
b/airflow-core/tests/unit/api_fastapi/execution_api/versions/head/test_task_instances.py
index 571057c12df..44a1fe5e088 100644
--- 
a/airflow-core/tests/unit/api_fastapi/execution_api/versions/head/test_task_instances.py
+++ 
b/airflow-core/tests/unit/api_fastapi/execution_api/versions/head/test_task_instances.py
@@ -17,6 +17,7 @@
 
 from __future__ import annotations
 
+import re
 from datetime import datetime
 from types import SimpleNamespace
 from typing import TYPE_CHECKING
@@ -58,6 +59,7 @@ from airflow.sdk import Asset, TaskGroup, TriggerRule, task, 
task_group
 from airflow.state.metastore import MetastoreBackend
 from airflow.utils.state import DagRunState, State, TaskInstanceState, 
TerminalTIState
 
+from tests_common.test_utils.asserts import capture_orm_selects
 from tests_common.test_utils.config import conf_vars
 from tests_common.test_utils.db import (
     clear_db_assets,
@@ -3884,6 +3886,29 @@ class TestGetPreviousTI:
         assert data["run_id"] == "target_run_1"
         assert data["state"] == State.SUCCESS
 
+    def test_get_previous_ti_query_is_bounded(self, client, session, 
create_task_instance):
+        """The single-row previous-TI lookup must ask the DB for one row."""
+        for i in range(5):
+            create_task_instance(
+                task_id="test_task",
+                state=State.SUCCESS,
+                logical_date=timezone.datetime(2025, 1, i + 1),
+                run_id=f"run{i + 1}",
+            )
+        session.commit()
+
+        with capture_orm_selects("task_instance") as statements:
+            response = client.get(
+                "/execution/task-instances/previous/dag/test_task",
+                params={"logical_date": "2025-01-05T00:00:00Z"},
+            )
+
+        assert response.status_code == 200
+        assert response.json()["run_id"] == "run4"
+        assert statements, "expected the endpoint to query the task_instance 
table"
+        for sql in statements:
+            assert re.search(r"\bLIMIT 1\b", sql), f"previous-TI lookup is not 
bounded to one row: {sql}"
+
 
 class TestGetTaskStates:
     def setup_method(self):

Reply via email to