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 562e7307c2c Fix N+1 query in bulk update for Variables and Pools 
(#71918)
562e7307c2c is described below

commit 562e7307c2cffa1af1dc74813a215221d4c7ad9b
Author: Jyun-An Chen <[email protected]>
AuthorDate: Thu Aug 27 23:21:22 2026 +0800

    Fix N+1 query in bulk update for Variables and Pools (#71918)
    
    BulkVariableService.handle_bulk_update and 
BulkPoolService.handle_bulk_update
    each ran one batched existence-check query up front, then discarded the
    result and re-queried every entity individually inside the loop. The
    sibling handle_bulk_delete methods in the same two files, and
    connections.py's handle_bulk_update, already reuse the batched-lookup
    dict correctly -- this was the one spot left over from the earlier
    bulk-delete N+1 fixes.
    
    update_orm_from_pydantic in both files now takes the already-fetched
    ORM object instead of a key/name string, matching the design
    connections.py already uses, so the update loop no longer needs its
    own per-item query.
---
 .../api_fastapi/core_api/routes/public/pools.py    | 10 ++++--
 .../core_api/routes/public/variables.py            | 11 +++++-
 .../api_fastapi/core_api/services/public/pools.py  | 21 +++++------
 .../core_api/services/public/variables.py          | 28 +++------------
 .../core_api/routes/public/test_pools.py           | 42 ++++++++++++++++++++++
 .../core_api/routes/public/test_variables.py       | 31 ++++++++++++++++
 6 files changed, 104 insertions(+), 39 deletions(-)

diff --git 
a/airflow-core/src/airflow/api_fastapi/core_api/routes/public/pools.py 
b/airflow-core/src/airflow/api_fastapi/core_api/routes/public/pools.py
index f2c0330c815..07f81b3a0d6 100644
--- a/airflow-core/src/airflow/api_fastapi/core_api/routes/public/pools.py
+++ b/airflow-core/src/airflow/api_fastapi/core_api/routes/public/pools.py
@@ -150,8 +150,14 @@ def patch_pool(
             "Invalid body, pool name from request body doesn't match uri 
parameter",
         )
 
-    pool = update_orm_from_pydantic(pool_name, patch_body, update_mask, 
session)
-    return pool
+    pool = session.scalar(select(Pool).where(Pool.pool == pool_name).limit(1))
+    if not pool:
+        raise HTTPException(
+            status.HTTP_404_NOT_FOUND, detail=f"The Pool with name: 
`{pool_name}` was not found"
+        )
+
+    updated_pool = update_orm_from_pydantic(pool, patch_body, update_mask)
+    return updated_pool
 
 
 @pools_router.post(
diff --git 
a/airflow-core/src/airflow/api_fastapi/core_api/routes/public/variables.py 
b/airflow-core/src/airflow/api_fastapi/core_api/routes/public/variables.py
index 705880c4898..e48031cd68d 100644
--- a/airflow-core/src/airflow/api_fastapi/core_api/routes/public/variables.py
+++ b/airflow-core/src/airflow/api_fastapi/core_api/routes/public/variables.py
@@ -149,7 +149,16 @@ def patch_variable(
     update_mask: list[str] | None = Query(None),
 ) -> VariableResponse:
     """Update a variable by key."""
-    variable = update_orm_from_pydantic(variable_key, patch_body, update_mask, 
session)
+    if patch_body.key != variable_key:
+        raise HTTPException(
+            status.HTTP_400_BAD_REQUEST, "Invalid body, key from request body 
doesn't match uri parameter"
+        )
+    old_variable = 
session.scalar(select(Variable).filter_by(key=variable_key).limit(1))
+    if not old_variable:
+        raise HTTPException(
+            status.HTTP_404_NOT_FOUND, f"The Variable with key: 
`{variable_key}` was not found"
+        )
+    variable = update_orm_from_pydantic(old_variable, patch_body, update_mask)
     return variable
 
 
diff --git 
a/airflow-core/src/airflow/api_fastapi/core_api/services/public/pools.py 
b/airflow-core/src/airflow/api_fastapi/core_api/services/public/pools.py
index 476c45b8f5c..1a058ec8f6a 100644
--- a/airflow-core/src/airflow/api_fastapi/core_api/services/public/pools.py
+++ b/airflow-core/src/airflow/api_fastapi/core_api/services/public/pools.py
@@ -24,7 +24,6 @@ from fastapi.exceptions import RequestValidationError
 from pydantic import ValidationError
 from sqlalchemy import select
 
-from airflow.api_fastapi.common.db.common import SessionDep
 from airflow.api_fastapi.core_api.datamodels.common import (
     BulkActionNotOnExistence,
     BulkActionOnExistence,
@@ -43,27 +42,21 @@ from airflow.models.pool import Pool
 
 
 def update_orm_from_pydantic(
-    pool_name: str,
+    pool: Pool,
     patch_body: PoolBody | PoolPatchBody,
     update_mask: list[str] | None,
-    session: SessionDep,
 ) -> Pool:
     """
     Update an existing pool.
 
-    :param pool_name: The name of the existing Pool to be updated.
+    :param pool: The existing Pool ORM object to update.
     :param patch_body: Pydantic model containing the fields to update.
     :param update_mask: Specific fields to update. If None, all provided 
fields will be considered.
-    :param session: The database session dependency.
     :return: The updated Pool instance.
     :raises HTTPException: If attempting to update disallowed fields on 
``default_pool``.
     """
     # Special restriction: default pool only allows limited fields to be 
patched
-    pool = session.scalar(select(Pool).where(Pool.pool == pool_name).limit(1))
-    if not pool:
-        raise HTTPException(
-            status.HTTP_404_NOT_FOUND, detail=f"The Pool with name: 
`{pool_name}` was not found"
-        )
+    pool_name = pool.pool
     if pool_name == Pool.DEFAULT_POOL_NAME:
         if update_mask and all(mask.strip() in {"slots", "include_deferred"} 
for mask in update_mask):
             # Validate only slots/include_deferred
@@ -170,7 +163,9 @@ class BulkPoolService(BulkService[PoolBody]):
     def handle_bulk_update(self, action: BulkUpdateAction[PoolBody], results: 
BulkActionResponse) -> None:
         """Bulk Update pools."""
         to_update_pool_names = {pool.pool for pool in action.entities}
-        _, matched_pool_names, not_found_pool_names = 
self.categorize_pools(to_update_pool_names)
+        existing_pools_dict, matched_pool_names, not_found_pool_names = 
self.categorize_pools(
+            to_update_pool_names
+        )
         try:
             if action.action_on_non_existence == BulkActionNotOnExistence.FAIL 
and not_found_pool_names:
                 raise HTTPException(
@@ -185,7 +180,9 @@ class BulkPoolService(BulkService[PoolBody]):
                 if pool.pool not in update_pool_names:
                     continue
 
-                updated_pool = update_orm_from_pydantic(pool.pool, pool, 
action.update_mask, self.session)
+                updated_pool = update_orm_from_pydantic(
+                    existing_pools_dict[pool.pool], pool, action.update_mask
+                )
 
                 results.success.append(str(updated_pool.pool))  # use request 
field, always consistent
 
diff --git 
a/airflow-core/src/airflow/api_fastapi/core_api/services/public/variables.py 
b/airflow-core/src/airflow/api_fastapi/core_api/services/public/variables.py
index 37941fed550..b5e63fb2d84 100644
--- a/airflow-core/src/airflow/api_fastapi/core_api/services/public/variables.py
+++ b/airflow-core/src/airflow/api_fastapi/core_api/services/public/variables.py
@@ -24,7 +24,6 @@ from fastapi.exceptions import RequestValidationError
 from pydantic import ValidationError
 from sqlalchemy import select
 
-from airflow.api_fastapi.common.db.common import SessionDep
 from airflow.api_fastapi.core_api.datamodels.common import (
     BulkActionNotOnExistence,
     BulkActionOnExistence,
@@ -42,30 +41,17 @@ from airflow.models.variable import Variable
 
 
 def update_orm_from_pydantic(
-    variable_key: str, patch_body: VariableBody, update_mask: list[str] | 
None, session: SessionDep
+    old_variable: Variable, patch_body: VariableBody, update_mask: list[str] | 
None
 ) -> Variable:
     """
     Update an existing Variable.
 
-    :param variable_key: The name of the existing Variable_key to update.
+    :param old_variable: The existing Variable ORM object to update.
     :param patch_body: The patch request body containing fields to update.
     :param update_mask: List of fields to update. If None, all provided fields 
will be updated.
-    :param session: The database session dependency.
     :return: The updated Variable object.
     :raises HTTPException: If attempting to update restricted fields (e.g., 
``key``).
     """
-    # Key field is immutable → cannot be patched
-
-    if patch_body.key != variable_key:
-        raise HTTPException(
-            status.HTTP_400_BAD_REQUEST, "Invalid body, key from request body 
doesn't match uri parameter"
-        )
-    old_variable = 
session.scalar(select(Variable).filter_by(key=variable_key).limit(1))
-    if not old_variable:
-        raise HTTPException(
-            status.HTTP_404_NOT_FOUND, f"The Variable with key: 
`{variable_key}` was not found"
-        )
-
     if update_mask:
         fields_to_update = patch_body.model_fields_set & set(update_mask)
         try:
@@ -79,12 +65,6 @@ def update_orm_from_pydantic(
             raise RequestValidationError(errors=e.errors())
     non_update_fields = {"key"}
 
-    if patch_body.key != old_variable.key:
-        raise HTTPException(
-            status.HTTP_400_BAD_REQUEST,
-            "Invalid body, key from request body doesn't match uri parameter",
-        )
-
     # Apply patch via utility
     return cast(
         "Variable",
@@ -141,7 +121,7 @@ class BulkVariableService(BulkService[VariableBody]):
     def handle_bulk_update(self, action: BulkUpdateAction, results: 
BulkActionResponse) -> None:
         """Bulk Update variables."""
         to_update_keys = {variable.key for variable in action.entities}
-        _, matched_keys, not_found_keys = self.categorize_keys(to_update_keys)
+        existing_variables_dict, matched_keys, not_found_keys = 
self.categorize_keys(to_update_keys)
         try:
             if action.action_on_non_existence == BulkActionNotOnExistence.FAIL 
and not_found_keys:
                 raise HTTPException(
@@ -157,7 +137,7 @@ class BulkVariableService(BulkService[VariableBody]):
                 if variable.key not in update_keys:
                     continue
                 updated_variable = update_orm_from_pydantic(
-                    variable.key, variable, action.update_mask, self.session
+                    existing_variables_dict[variable.key], variable, 
action.update_mask
                 )
 
                 results.success.append(updated_variable.key)
diff --git 
a/airflow-core/tests/unit/api_fastapi/core_api/routes/public/test_pools.py 
b/airflow-core/tests/unit/api_fastapi/core_api/routes/public/test_pools.py
index d56aceecfa0..92c197e7423 100644
--- a/airflow-core/tests/unit/api_fastapi/core_api/routes/public/test_pools.py
+++ b/airflow-core/tests/unit/api_fastapi/core_api/routes/public/test_pools.py
@@ -1204,6 +1204,48 @@ class TestBulkPools(TestPoolsEndpoint):
             f"A regression that re-queries pools inside the loop would add one 
SELECT per pool."
         )
 
+    @pytest.mark.parametrize(
+        ("pool_count"),
+        [5, 10, 20],
+    )
+    def test_bulk_update_query_count_is_independent_of_pool_count(self, 
test_client, session, pool_count):
+        # Regression guard for the N+1 fix in 
BulkPoolService.handle_bulk_update:
+        # the query count for a bulk update must be the same regardless of how
+        # many pools are updated. A regression that re-queries each pool inside
+        # the loop would add one SELECT per pool, so the larger run would issue
+        # strictly more queries than the smaller one.
+
+        EXPECTED_QUERY_COUNT = 4
+
+        pool_names = [f"perf_update_pool_{pool_count}_{i}" for i in 
range(pool_count)]
+        session.add_all(Pool(pool=name, slots=1, include_deferred=False) for 
name in pool_names)
+        session.commit()
+
+        request_body = {
+            "actions": [
+                {
+                    "action": "update",
+                    "entities": [
+                        {"name": name, "slots": 99, "include_deferred": False} 
for name in pool_names
+                    ],
+                    "action_on_non_existence": "fail",
+                }
+            ]
+        }
+
+        with count_queries() as result:
+            response = test_client.patch("/pools", json=request_body)
+
+        assert response.status_code == 200
+        assert sorted(response.json()["update"]["success"]) == 
sorted(pool_names)
+
+        query_count = sum(result.values())
+
+        assert query_count == EXPECTED_QUERY_COUNT, (
+            f"Bulk-update query count {query_count} does not match expected 
{EXPECTED_QUERY_COUNT}. "
+            f"A regression that re-queries pools inside the loop would add one 
SELECT per pool."
+        )
+
     def test_should_respond_401(self, unauthenticated_test_client):
         response = unauthenticated_test_client.patch("/pools", json={})
         assert response.status_code == 401
diff --git 
a/airflow-core/tests/unit/api_fastapi/core_api/routes/public/test_variables.py 
b/airflow-core/tests/unit/api_fastapi/core_api/routes/public/test_variables.py
index 1198f748481..ec5f915dc43 100644
--- 
a/airflow-core/tests/unit/api_fastapi/core_api/routes/public/test_variables.py
+++ 
b/airflow-core/tests/unit/api_fastapi/core_api/routes/public/test_variables.py
@@ -1605,3 +1605,34 @@ class TestBulkVariables(TestVariableEndpoint):
 
         session.commit()
         assert 
session.scalars(select(Variable.key).where(Variable.key.in_(keys))).all() == []
+
+    @pytest.mark.parametrize("num_variables", [1, 25])
+    def test_bulk_update_resolves_existence_in_single_query(self, session, 
num_variables):
+        """Bulk update looks up all targeted variables in one query, not one 
per key (no N+1)."""
+        keys = [f"bulk_update_var_{i}" for i in range(num_variables)]
+        for key in keys:
+            Variable.set(key=key, value="old_value", session=session)
+        session.commit()
+
+        request = BulkBody[VariableBody].model_validate(
+            {
+                "actions": [
+                    {
+                        "action": "update",
+                        "entities": [{"key": key, "value": "new_value"} for 
key in keys],
+                        "action_on_non_existence": "skip",
+                    }
+                ]
+            }
+        )
+        service = BulkVariableService(session=session, request=request)
+
+        with assert_queries_count(1):
+            response = service.handle_request()
+
+        assert response.update is not None
+        assert sorted(response.update.success) == sorted(keys)
+
+        session.commit()
+        updated_variables = 
session.scalars(select(Variable).where(Variable.key.in_(keys))).all()
+        assert {variable.val for variable in updated_variables} == 
{"new_value"}

Reply via email to