This is an automated email from the ASF dual-hosted git repository.

vincbeck 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 1fb77d1d104 Cache bundle-to-team name lookups in dag processor manager 
(#68730)
1fb77d1d104 is described below

commit 1fb77d1d104b584405a6f88c69dde418d7fd4513
Author: D. Ferruzzi <[email protected]>
AuthorDate: Mon Jun 22 07:17:59 2026 -0700

    Cache bundle-to-team name lookups in dag processor manager (#68730)
---
 airflow-core/src/airflow/dag_processing/manager.py | 69 ++++++-----------
 airflow-core/src/airflow/models/dagbundle.py       | 17 +++++
 .../tests/unit/dag_processing/test_manager.py      | 89 +++++++++++++++++-----
 airflow-core/tests/unit/models/test_dagbundle.py   | 17 +++++
 4 files changed, 131 insertions(+), 61 deletions(-)

diff --git a/airflow-core/src/airflow/dag_processing/manager.py 
b/airflow-core/src/airflow/dag_processing/manager.py
index e43920f2618..b2428985a65 100644
--- a/airflow-core/src/airflow/dag_processing/manager.py
+++ b/airflow-core/src/airflow/dag_processing/manager.py
@@ -85,7 +85,7 @@ from airflow.utils.sqlalchemy import (
 )
 
 if TYPE_CHECKING:
-    from collections.abc import Callable, Iterable, Iterator, Sequence
+    from collections.abc import Callable, Collection, Iterable, Iterator, 
Sequence
     from socket import socket
 
     from sqlalchemy.orm import Session
@@ -266,6 +266,8 @@ class DagFileProcessorManager(LoggingMixin):
     _dag_bundles: list[BaseDagBundle] = attrs.field(factory=list, init=False)
     _bundle_versions: dict[str, str | None] = attrs.field(factory=dict, 
init=False)
     _bundle_version_data: dict[str, dict | None] = attrs.field(factory=dict, 
init=False)
+    _multi_team: bool = attrs.field(factory=lambda: conf.getboolean("core", 
"multi_team"), init=False)
+    _bundle_name_to_team_name: dict[str, str | None] = 
attrs.field(factory=dict, init=False)
 
     _processors: dict[DagFileInfo, DagFileProcessorProcess] = 
attrs.field(factory=dict, init=False)
 
@@ -307,6 +309,19 @@ class DagFileProcessorManager(LoggingMixin):
         # So that we ignore the debug dump signal, making it easier to send
         signal.signal(signal.SIGUSR2, signal.SIG_IGN)
 
+    def _get_team_names(self, bundle_names: Collection[str]) -> dict[str, str 
| None]:
+        if not self._multi_team or not bundle_names:
+            return {}
+        missing = [name for name in bundle_names if name not in 
self._bundle_name_to_team_name]
+        if missing:
+            queried = DagBundleModel.get_team_names(missing)
+            for name in missing:
+                self._bundle_name_to_team_name[name] = queried.get(name)
+        return {name: self._bundle_name_to_team_name.get(name) for name in 
bundle_names}
+
+    def _get_team_name(self, bundle_name: str) -> str | None:
+        return self._get_team_names({bundle_name}).get(bundle_name)
+
     def _exit_gracefully(self, signum, frame):
         """Clean up DAG file processors to avoid leaving orphan processes."""
         self.log.info("Exiting gracefully upon receiving signal %s", signum)
@@ -720,11 +735,7 @@ class DagFileProcessorManager(LoggingMixin):
         )
         self._callback_to_execute[file_info].append(request)
         self._add_files_to_queue([file_info], mode="front")
-        team_name = (
-            DagBundleModel.get_team_name(file_info.bundle_name)
-            if conf.getboolean("core", "multi_team")
-            else None
-        )
+        team_name = self._get_team_name(file_info.bundle_name)
         stats.incr("dag_processing.other_callback_count", 
tags=prune_dict({"team_name": team_name}))
 
     @provide_session
@@ -891,6 +902,8 @@ class DagFileProcessorManager(LoggingMixin):
             )
 
         if any_refreshed:
+            # Bundle-to-team assignments can only change on bundle refresh, so 
clear the cache.
+            self._bundle_name_to_team_name = {}
             self.handle_removed_files(known_files=known_files)
             self._resort_file_queue()
             self._add_new_files_to_queue(known_files=known_files)
@@ -1026,13 +1039,7 @@ class DagFileProcessorManager(LoggingMixin):
         utcnow = timezone.utcnow()
         now = time.monotonic()
 
-        if conf.getboolean("core", "multi_team"):
-            bundle_names = {bundle_name for bundle_name in known_files}
-            bundle_to_team = {
-                bundle_name: DagBundleModel.get_team_name(bundle_name) for 
bundle_name in bundle_names
-            }
-        else:
-            bundle_to_team = {}
+        bundle_to_team = self._get_team_names({bundle_name for bundle_name in 
known_files})
 
         for files in known_files.values():
             for file in files:
@@ -1154,13 +1161,7 @@ class DagFileProcessorManager(LoggingMixin):
         """Stop processors that are working on deleted files."""
         present_keys = {file.presence_key for file in present}
 
-        if conf.getboolean("core", "multi_team"):
-            bundle_names = {file.bundle_name for file in self._processors}
-            bundle_to_team = {
-                bundle_name: DagBundleModel.get_team_name(bundle_name) for 
bundle_name in bundle_names
-            }
-        else:
-            bundle_to_team = {}
+        bundle_to_team = self._get_team_names({file.bundle_name for file in 
self._processors})
 
         for file in list(self._processors.keys()):
             if file.presence_key not in present_keys:
@@ -1215,9 +1216,7 @@ class DagFileProcessorManager(LoggingMixin):
 
         run_duration = time.monotonic() - proc.start_time
         finish_time = timezone.utcnow()
-        team_name = (
-            DagBundleModel.get_team_name(file.bundle_name) if 
conf.getboolean("core", "multi_team") else None
-        )
+        team_name = self._get_team_name(file.bundle_name)
         next_stat = process_parse_results(
             run_duration=run_duration,
             finish_time=finish_time,
@@ -1394,13 +1393,7 @@ class DagFileProcessorManager(LoggingMixin):
 
     def _start_new_processes(self):
         """Start more processors if we have enough slots and files to 
process."""
-        if conf.getboolean("core", "multi_team"):
-            bundle_names = {file.bundle_name for file in self._file_queue}
-            bundle_to_team = {
-                bundle_name: DagBundleModel.get_team_name(bundle_name) for 
bundle_name in bundle_names
-            }
-        else:
-            bundle_to_team = {}
+        bundle_to_team = self._get_team_names({file.bundle_name for file in 
self._file_queue})
 
         while self._parallelism > len(self._processors) and self._file_queue:
             file, _ = self._file_queue.popitem(last=False)
@@ -1584,13 +1577,7 @@ class DagFileProcessorManager(LoggingMixin):
         now = time.monotonic()
         processors_to_remove = []
 
-        if conf.getboolean("core", "multi_team"):
-            bundle_names = {file.bundle_name for file in self._processors}
-            bundle_to_team = {
-                bundle_name: DagBundleModel.get_team_name(bundle_name) for 
bundle_name in bundle_names
-            }
-        else:
-            bundle_to_team = {}
+        bundle_to_team = self._get_team_names({file.bundle_name for file in 
self._processors})
 
         for file, processor in self._processors.items():
             duration = now - processor.start_time
@@ -1674,13 +1661,7 @@ class DagFileProcessorManager(LoggingMixin):
 
     def terminate(self):
         """Stop all running processors."""
-        if conf.getboolean("core", "multi_team"):
-            bundle_names = {file.bundle_name for file in self._processors}
-            bundle_to_team = {
-                bundle_name: DagBundleModel.get_team_name(bundle_name) for 
bundle_name in bundle_names
-            }
-        else:
-            bundle_to_team = {}
+        bundle_to_team = self._get_team_names({file.bundle_name for file in 
self._processors})
 
         for file, processor in self._processors.items():
             stats.decr(
diff --git a/airflow-core/src/airflow/models/dagbundle.py 
b/airflow-core/src/airflow/models/dagbundle.py
index abd723a4e67..f18c548a019 100644
--- a/airflow-core/src/airflow/models/dagbundle.py
+++ b/airflow-core/src/airflow/models/dagbundle.py
@@ -30,6 +30,8 @@ from airflow.utils.session import NEW_SESSION, provide_session
 from airflow.utils.sqlalchemy import UtcDateTime
 
 if TYPE_CHECKING:
+    from collections.abc import Collection
+
     from sqlalchemy.orm import Session
 
 
@@ -121,3 +123,18 @@ class DagBundleModel(Base, LoggingMixin):
         return session.scalar(
             
select(Team.name).join(DagBundleModel.teams).where(DagBundleModel.name == 
bundle_name)
         )
+
+    @staticmethod
+    @provide_session
+    def get_team_names(
+        bundle_names: Collection[str], *, session: Session = NEW_SESSION
+    ) -> dict[str, str | None]:
+        """Return a mapping of bundle name to team name (None for bundles not 
mapped to a team)."""
+        if not bundle_names:
+            return {}
+        rows = session.execute(
+            select(DagBundleModel.name, Team.name)
+            .join(DagBundleModel.teams)
+            .where(DagBundleModel.name.in_(bundle_names))
+        ).all()
+        return {name: team_name for name, team_name in rows}
diff --git a/airflow-core/tests/unit/dag_processing/test_manager.py 
b/airflow-core/tests/unit/dag_processing/test_manager.py
index 6d10e5c1a06..4b55b6b2f1e 100644
--- a/airflow-core/tests/unit/dag_processing/test_manager.py
+++ b/airflow-core/tests/unit/dag_processing/test_manager.py
@@ -2732,6 +2732,15 @@ class TestDagFileProcessorManager:
         mock_update.assert_called_once_with("mock_bundle", 
last_refreshed=mock.ANY, version=None)
         assert manager._bundle_versions["mock_bundle"] is None
 
+    def test_refresh_dag_bundles_clears_team_name_cache(self):
+        manager = DagFileProcessorManager(max_runs=1)
+        manager._bundle_name_to_team_name = {"stale_bundle": "old_team"}
+        bundle = self._make_refresh_bundle(supports_versioning=False)
+
+        self._refresh_with_mocked_state(manager, bundle, 
BundleState(last_refreshed=None, version=None))
+
+        assert manager._bundle_name_to_team_name == {}
+
     def 
test_refresh_dag_bundles_versioned_version_changed_calls_update_bundle_state(self):
         """Versioned bundle with new version: update_bundle_state called with 
the new version."""
         manager = DagFileProcessorManager(max_runs=1)
@@ -2948,7 +2957,9 @@ class TestMultiTeamMetrics:
         return ret
 
     @conf_vars({("core", "multi_team"): "true"})
-    @mock.patch("airflow.dag_processing.manager.DagBundleModel.get_team_name", 
return_value="team_alpha")
+    @mock.patch(
+        "airflow.dag_processing.manager.DagBundleModel.get_team_names", 
return_value={"testing": "team_alpha"}
+    )
     @mock.patch("airflow.dag_processing.manager.stats.gauge")
     def test_log_file_processing_stats_includes_team_name(self, mock_gauge, 
mock_get_team_name):
         manager = DagFileProcessorManager(max_runs=1)
@@ -2975,7 +2986,7 @@ class TestMultiTeamMetrics:
         )
 
     @conf_vars({("core", "multi_team"): "false"})
-    @mock.patch("airflow.dag_processing.manager.DagBundleModel.get_team_name")
+    @mock.patch("airflow.dag_processing.manager.DagBundleModel.get_team_names")
     @mock.patch("airflow.dag_processing.manager.stats.gauge")
     def test_log_file_processing_stats_omits_team_name_when_not_multi_team(
         self, mock_gauge, mock_get_team_name
@@ -3004,7 +3015,9 @@ class TestMultiTeamMetrics:
         )
 
     @conf_vars({("core", "multi_team"): "true"})
-    @mock.patch("airflow.dag_processing.manager.DagBundleModel.get_team_name", 
return_value="team_alpha")
+    @mock.patch(
+        "airflow.dag_processing.manager.DagBundleModel.get_team_names", 
return_value={"testing": "team_alpha"}
+    )
     @mock.patch("airflow.dag_processing.manager.stats.incr")
     def test_start_new_processes_includes_team_name(self, mock_incr, 
mock_get_team_name):
         manager = DagFileProcessorManager(max_runs=1)
@@ -3024,7 +3037,9 @@ class TestMultiTeamMetrics:
         )
 
     @conf_vars({("core", "multi_team"): "true"})
-    @mock.patch("airflow.dag_processing.manager.DagBundleModel.get_team_name", 
return_value="team_alpha")
+    @mock.patch(
+        "airflow.dag_processing.manager.DagBundleModel.get_team_names", 
return_value={"testing": "team_alpha"}
+    )
     @mock.patch("airflow.dag_processing.manager.stats.incr")
     @mock.patch("airflow.dag_processing.manager.stats.decr")
     def test_kill_timed_out_processors_includes_team_name(self, mock_decr, 
mock_incr, mock_get_team_name):
@@ -3049,7 +3064,9 @@ class TestMultiTeamMetrics:
         )
 
     @conf_vars({("core", "multi_team"): "true"})
-    @mock.patch("airflow.dag_processing.manager.DagBundleModel.get_team_name", 
return_value="team_alpha")
+    @mock.patch(
+        "airflow.dag_processing.manager.DagBundleModel.get_team_names", 
return_value={"testing": "team_alpha"}
+    )
     @mock.patch("airflow.dag_processing.manager.stats.timing")
     def test_process_parse_results_includes_team_name(self, mock_timing, 
mock_get_team_name):
         from airflow.dag_processing.manager import process_parse_results
@@ -3097,13 +3114,13 @@ class TestMultiTeamMetrics:
         ("multi_team", "team_name", "expected_tags"),
         [
             pytest.param(
-                "true",
+                True,
                 "team_alpha",
                 {"file_path": "dag_file.py", "action": "stop", "team_name": 
"team_alpha"},
                 id="with_team",
             ),
             pytest.param(
-                "false",
+                False,
                 None,
                 {"file_path": "dag_file.py", "action": "stop"},
                 id="without_team",
@@ -3115,6 +3132,7 @@ class TestMultiTeamMetrics:
         self, mock_decr, multi_team, team_name, expected_tags
     ):
         manager = DagFileProcessorManager(max_runs=1)
+        manager._multi_team = multi_team
         dag_file = DagFileInfo(
             bundle_name="testing", rel_path=Path("dag_file.py"), 
bundle_path=TEST_DAGS_FOLDER
         )
@@ -3122,8 +3140,10 @@ class TestMultiTeamMetrics:
         manager._processors = {dag_file: processor}
 
         with (
-            conf_vars({("core", "multi_team"): multi_team}),
-            
mock.patch("airflow.dag_processing.manager.DagBundleModel.get_team_name", 
return_value=team_name),
+            mock.patch(
+                "airflow.dag_processing.manager.DagBundleModel.get_team_names",
+                return_value={"testing": team_name},
+            ),
             mock.patch.object(type(processor), "kill"),
         ):
             # Empty "present" set means the file is orphaned, so its processor 
is stopped.
@@ -3135,13 +3155,13 @@ class TestMultiTeamMetrics:
         ("multi_team", "team_name", "expected_tags"),
         [
             pytest.param(
-                "true",
+                True,
                 "team_alpha",
                 {"file_path": "dag_file.py", "action": "terminate", 
"team_name": "team_alpha"},
                 id="with_team",
             ),
             pytest.param(
-                "false",
+                False,
                 None,
                 {"file_path": "dag_file.py", "action": "terminate"},
                 id="without_team",
@@ -3151,6 +3171,7 @@ class TestMultiTeamMetrics:
     @mock.patch("airflow.dag_processing.manager.stats.decr")
     def test_terminate_includes_team_name(self, mock_decr, multi_team, 
team_name, expected_tags):
         manager = DagFileProcessorManager(max_runs=1)
+        manager._multi_team = multi_team
         dag_file = DagFileInfo(
             bundle_name="testing", rel_path=Path("dag_file.py"), 
bundle_path=TEST_DAGS_FOLDER
         )
@@ -3158,8 +3179,10 @@ class TestMultiTeamMetrics:
         manager._processors = {dag_file: processor}
 
         with (
-            conf_vars({("core", "multi_team"): multi_team}),
-            
mock.patch("airflow.dag_processing.manager.DagBundleModel.get_team_name", 
return_value=team_name),
+            mock.patch(
+                "airflow.dag_processing.manager.DagBundleModel.get_team_names",
+                return_value={"testing": team_name},
+            ),
             mock.patch.object(type(processor), "kill"),
         ):
             manager.terminate()
@@ -3195,22 +3218,54 @@ class TestMultiTeamMetrics:
     @pytest.mark.parametrize(
         ("multi_team", "team_name", "expected_tags"),
         [
-            pytest.param("true", "team_alpha", {"team_name": "team_alpha"}, 
id="with_team"),
-            pytest.param("false", None, {}, id="without_team"),
+            pytest.param(True, "team_alpha", {"team_name": "team_alpha"}, 
id="with_team"),
+            pytest.param(False, None, {}, id="without_team"),
         ],
     )
     @mock.patch("airflow.dag_processing.manager.stats.incr")
     def test_add_callback_to_queue_includes_team_name(self, mock_incr, 
multi_team, team_name, expected_tags):
         manager = DagFileProcessorManager(max_runs=1)
+        manager._multi_team = multi_team
         request = MagicMock(filepath="test_dag.py", bundle_name="testing", 
bundle_version=None)
         bundle = MagicMock(path=TEST_DAGS_FOLDER)
 
         with (
-            conf_vars({("core", "multi_team"): multi_team}),
             mock.patch.object(manager, "prepare_callback_bundle", 
return_value=bundle),
             mock.patch.object(manager, "_add_files_to_queue"),
-            
mock.patch("airflow.dag_processing.manager.DagBundleModel.get_team_name", 
return_value=team_name),
+            mock.patch(
+                "airflow.dag_processing.manager.DagBundleModel.get_team_names",
+                return_value={"testing": team_name},
+            ),
         ):
             manager._add_callback_to_queue(request)
 
         
mock_incr.assert_called_once_with("dag_processing.other_callback_count", 
tags=expected_tags)
+
+    @mock.patch(
+        "airflow.dag_processing.manager.DagBundleModel.get_team_names",
+        return_value={"bundle_a": "team_alpha"},
+    )
+    def test_get_team_name_caches_lookup(self, mock_get_team_names):
+        manager = DagFileProcessorManager(max_runs=1)
+        manager._multi_team = True
+
+        assert manager._get_team_name("bundle_a") == "team_alpha"
+        assert manager._get_team_name("bundle_a") == "team_alpha"
+        assert manager._get_team_name("bundle_a") == "team_alpha"
+
+        mock_get_team_names.assert_called_once()
+
+    @mock.patch(
+        "airflow.dag_processing.manager.DagBundleModel.get_team_names",
+        return_value={"bundle_a": "team_alpha", "bundle_b": "team_alpha"},
+    )
+    def test_get_team_names_batches_and_caches(self, mock_get_team_names):
+        manager = DagFileProcessorManager(max_runs=1)
+        manager._multi_team = True
+
+        manager._get_team_names({"bundle_a", "bundle_b"})
+        manager._get_team_names({"bundle_a", "bundle_b"})
+
+        # Two bundles resolved in a single batched query; the repeat call is 
served from cache.
+        mock_get_team_names.assert_called_once()
+        assert manager._bundle_name_to_team_name == {"bundle_a": "team_alpha", 
"bundle_b": "team_alpha"}
diff --git a/airflow-core/tests/unit/models/test_dagbundle.py 
b/airflow-core/tests/unit/models/test_dagbundle.py
index 84223e34a9b..0c5c5def9d7 100644
--- a/airflow-core/tests/unit/models/test_dagbundle.py
+++ b/airflow-core/tests/unit/models/test_dagbundle.py
@@ -58,3 +58,20 @@ class TestDagBundleModel:
 
     def test_get_team_name_unknown_bundle(self, session: Session):
         assert DagBundleModel.get_team_name("does_not_exist", session=session) 
is None
+
+    def test_get_team_names(self, testing_team: Team, session: Session):
+        mapped = DagBundleModel(name="mapped_bundle")
+        mapped.teams.append(testing_team)
+        unmapped = DagBundleModel(name="unmapped_bundle")
+        session.add_all([mapped, unmapped])
+        session.flush()
+
+        result = DagBundleModel.get_team_names(
+            ["mapped_bundle", "unmapped_bundle", "does_not_exist"], 
session=session
+        )
+
+        # Only bundles actually mapped to a team are returned; callers treat 
absent keys as None.
+        assert result == {"mapped_bundle": "testing"}
+
+    def test_get_team_names_empty(self, session: Session):
+        assert DagBundleModel.get_team_names([], session=session) == {}

Reply via email to