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

vatsrahul1001 pushed a commit to branch v3-4-test
in repository https://gitbox.apache.org/repos/asf/airflow.git


The following commit(s) were added to refs/heads/v3-4-test by this push:
     new 63f9ab0e144 [v3-4-test] Read a team's running task logs through the 
team's executor (#75255) (#75408)
63f9ab0e144 is described below

commit 63f9ab0e14452865da7501eb78cc3e4aac958c2f
Author: github-actions[bot] 
<41898282+github-actions[bot]@users.noreply.github.com>
AuthorDate: Thu Oct 22 23:48:43 2026 +0530

    [v3-4-test] Read a team's running task logs through the team's executor 
(#75255) (#75408)
    
    In multi-team mode, the API server reads a running task's logs through the
    global default executor, or looks up the task's named executor in the
    global scope only. A team's executor has its own config, such as the
    cluster its KubernetesExecutor pods run on, so a team task's live logs came
    from the wrong executor: nothing from a global LocalExecutor, or "Cannot
    find pod" from a global KubernetesExecutor on another cluster.
    
    Resolve the executor the same way the scheduler does: the team's executor,
    and a global one if the team doesn't have it.
    (cherry picked from commit 1e3a23293b834db9c362c123bf9580ddef81800a)
    
    Co-authored-by: Sean Ghaeli <[email protected]>
---
 .../src/airflow/executors/executor_loader.py       | 36 ++++++++++++++++
 .../src/airflow/utils/log/file_task_handler.py     | 17 ++++++--
 .../tests/unit/executors/test_executor_loader.py   | 48 +++++++++++++++++++++-
 airflow-core/tests/unit/utils/test_log_handlers.py | 30 ++++++++++++++
 4 files changed, 127 insertions(+), 4 deletions(-)

diff --git a/airflow-core/src/airflow/executors/executor_loader.py 
b/airflow-core/src/airflow/executors/executor_loader.py
index 79555d49daf..101a5fc48e6 100644
--- a/airflow-core/src/airflow/executors/executor_loader.py
+++ b/airflow-core/src/airflow/executors/executor_loader.py
@@ -38,6 +38,8 @@ from airflow.models.team import Team
 log = structlog.get_logger(__name__)
 
 if TYPE_CHECKING:
+    from collections.abc import Iterable
+
     from airflow.executors.base_executor import BaseExecutor
 
 
@@ -332,6 +334,40 @@ class ExecutorLoader:
             return executor_name
         raise UnknownExecutorException(f"Unknown executor being loaded: 
{executor_name_str}")
 
+    @classmethod
+    def resolve_executor_name(
+        cls,
+        executor: str | None,
+        team_name: str | None = None,
+        *,
+        executor_names: Iterable[ExecutorName] | None = None,
+    ) -> ExecutorName:
+        """
+        Return the executor name for a task's ``executor`` and team.
+
+        The team's own executor wins over a global one, and a named executor 
that doesn't match raises
+        instead of falling back to the default.
+
+        :param executor: alias, module path or class name, or None for the 
default
+        :param team_name: the task's team
+        :param executor_names: names to search, the configured ones by default
+        """
+        global_match = None
+        for name in cls._get_executor_names() if executor_names is None else 
executor_names:
+            if executor is not None and executor not in (
+                name.alias,
+                name.module_path,
+                name.module_path.split(".")[-1],
+            ):
+                continue
+            if team_name and name.team_name == team_name:
+                return name
+            if name.team_name is None and global_match is None:
+                global_match = name
+        if global_match is None:
+            raise UnknownExecutorException(f"Unknown executor {executor!r} for 
team {team_name!r}")
+        return global_match
+
     @classmethod
     def load_executor(cls, executor_name: ExecutorName | str | None) -> 
BaseExecutor:
         """
diff --git a/airflow-core/src/airflow/utils/log/file_task_handler.py 
b/airflow-core/src/airflow/utils/log/file_task_handler.py
index a2cd4c29322..f20db27722b 100644
--- a/airflow-core/src/airflow/utils/log/file_task_handler.py
+++ b/airflow-core/src/airflow/utils/log/file_task_handler.py
@@ -531,13 +531,24 @@ class FileTaskHandler(logging.Handler):
         :return: executor of the task instance
         """
         executor_name = ti.executor or self.DEFAULT_EXECUTOR_KEY
-        executor = self.executor_instances.get(executor_name)
+        team_name = None
+        if conf.getboolean("core", "multi_team"):
+            from airflow.models.dag import DagModel
+
+            team_name = DagModel.get_team_name(ti.dag_id)
+        # A team's executor has its own config, such as the cluster its pods 
run on.
+        cache_key = f"{team_name}:{executor_name}" if team_name else 
executor_name
+        executor = self.executor_instances.get(cache_key)
         if executor is None:
-            if executor_name == self.DEFAULT_EXECUTOR_KEY:
+            if team_name:
+                executor = ExecutorLoader.load_executor(
+                    ExecutorLoader.resolve_executor_name(ti.executor, 
team_name)
+                )
+            elif executor_name == self.DEFAULT_EXECUTOR_KEY:
                 executor = ExecutorLoader.get_default_executor()
             else:
                 executor = ExecutorLoader.load_executor(executor_name)
-            self.executor_instances[executor_name] = executor
+            self.executor_instances[cache_key] = executor
 
         return executor
 
diff --git a/airflow-core/tests/unit/executors/test_executor_loader.py 
b/airflow-core/tests/unit/executors/test_executor_loader.py
index c0c0a2f6331..550e0fa708f 100644
--- a/airflow-core/tests/unit/executors/test_executor_loader.py
+++ b/airflow-core/tests/unit/executors/test_executor_loader.py
@@ -21,7 +21,7 @@ from unittest.mock import patch
 
 import pytest
 
-from airflow.exceptions import AirflowConfigException
+from airflow.exceptions import AirflowConfigException, UnknownExecutorException
 from airflow.executors import executor_loader
 from airflow.executors.executor_loader import ConnectorSource, ExecutorName
 from airflow.executors.local_executor import LocalExecutor
@@ -37,6 +37,13 @@ celery_executor = 
pytest.importorskip("airflow.providers.celery.executors.celery
 ecs_executor = 
pytest.importorskip("airflow.providers.amazon.aws.executors.ecs.ecs_executor")
 
 
+TEAM_EXECUTORS_CONFIG = {
+    ("core", "multi_team"): "True",
+    ("core", "executor"): 
"global_celery_exec:CeleryExecutor,LocalExecutor,pkg_a.mod.MyExec,pkg_b.mod.MyExec;"
+    "team_a=team_celery_exec:CeleryExecutor;team_b=KubernetesExecutor",
+}
+
+
 @pytest.mark.usefixtures("clean_executor_loader")
 class TestExecutorLoader:
     def test_no_executor_configured(self):
@@ -749,6 +756,45 @@ class TestExecutorLoader:
                 )
                 assert result.alias == "LocalExecutor"
 
+    @pytest.mark.parametrize(
+        ("executor", "team_name", "expected"),
+        [
+            pytest.param("CeleryExecutor", "team_a", ("team_celery_exec", 
"team_a"), id="classname-to-team"),
+            pytest.param(
+                
"airflow.providers.celery.executors.celery_executor.CeleryExecutor",
+                "team_a",
+                ("team_celery_exec", "team_a"),
+                id="module-path-to-team",
+            ),
+            pytest.param("team_celery_exec", "team_a", ("team_celery_exec", 
"team_a"), id="team-alias"),
+            pytest.param("global_celery_exec", "team_a", 
("global_celery_exec", None), id="global-alias"),
+            pytest.param("LocalExecutor", "team_a", ("LocalExecutor", None), 
id="global-fallback"),
+            pytest.param(None, "team_a", ("team_celery_exec", "team_a"), 
id="team-default"),
+            pytest.param(None, "team_c", ("global_celery_exec", None), 
id="team-without-executors-default"),
+            pytest.param(None, None, ("global_celery_exec", None), 
id="global-default"),
+            pytest.param("MyExec", None, ("pkg_a.mod.MyExec", None), 
id="first-of-several-global-matches"),
+        ],
+    )
+    @conf_vars(TEAM_EXECUTORS_CONFIG)
+    @patch.object(executor_loader.ExecutorLoader, 
"_validate_teams_exist_in_database")
+    def test_resolve_executor_name(self, _, executor, team_name, expected):
+        name = executor_loader.ExecutorLoader.resolve_executor_name(executor, 
team_name)
+        assert (name.alias or name.module_path, name.team_name) == expected
+
+    @pytest.mark.parametrize(
+        ("executor", "team_name"),
+        [
+            pytest.param("team_celery_exec", None, 
id="team-alias-without-team"),
+            pytest.param("KubernetesExecutor", "team_a", 
id="other-teams-executor"),
+            pytest.param("EcsExecutor", "team_a", 
id="unknown-does-not-fall-back-to-default"),
+        ],
+    )
+    @conf_vars(TEAM_EXECUTORS_CONFIG)
+    @patch.object(executor_loader.ExecutorLoader, 
"_validate_teams_exist_in_database")
+    def test_resolve_executor_name_no_match(self, _, executor, team_name):
+        with pytest.raises(UnknownExecutorException):
+            executor_loader.ExecutorLoader.resolve_executor_name(executor, 
team_name)
+
     def test_get_executor_names_default_validates_teams(self):
         """Test that get_executor_names validates teams by default."""
         with patch.object(executor_loader.Team, "get_all_team_names") as 
mock_get_team_names:
diff --git a/airflow-core/tests/unit/utils/test_log_handlers.py 
b/airflow-core/tests/unit/utils/test_log_handlers.py
index a29e73b0ec0..f779ac8b44b 100644
--- a/airflow-core/tests/unit/utils/test_log_handlers.py
+++ b/airflow-core/tests/unit/utils/test_log_handlers.py
@@ -336,6 +336,36 @@ class TestFileTaskLogHandler:
                 mock_get_default_executor.assert_not_called()
                 mock_load_executor.assert_called_once_with(executor_name)
 
+    @pytest.mark.parametrize(
+        ("dag_id", "ti_executor", "expected"),
+        [
+            pytest.param("team_a_dag", None, ("LocalExecutor", "team_a"), 
id="team-default"),
+            pytest.param("team_b_dag", None, ("CeleryExecutor", "team_b"), 
id="other-team-default"),
+            pytest.param("team_a_dag", "LocalExecutor", ("LocalExecutor", 
"team_a"), id="team-named"),
+            pytest.param("team_a_dag", "CeleryExecutor", ("CeleryExecutor", 
None), id="global-named"),
+            pytest.param("global_dag", None, ("CeleryExecutor", None), 
id="no-team"),
+        ],
+    )
+    @conf_vars(
+        {
+            ("core", "multi_team"): "True",
+            ("core", "executor"): 
"CeleryExecutor;team_a=LocalExecutor;team_b=CeleryExecutor",
+        }
+    )
+    def test_get_executor_uses_the_teams_executor(self, dag_id, ti_executor, 
expected, clean_executor_loader):
+        teams = {"team_a_dag": "team_a", "team_b_dag": "team_b"}
+        ti = mock.MagicMock(dag_id=dag_id, executor=ti_executor)
+        handler = FileTaskHandler("")
+        handler.executor_instances = {}
+        with (
+            mock.patch.object(executor_loader.ExecutorLoader, 
"_validate_teams_exist_in_database"),
+            mock.patch("airflow.models.dag.DagModel.get_team_name", 
side_effect=teams.get),
+            mock.patch.object(executor_loader.ExecutorLoader, "load_executor") 
as load_executor,
+        ):
+            handler._get_executor(ti)
+        (executor_name,), _ = load_executor.call_args
+        assert (executor_name.alias, executor_name.team_name) == expected
+
     def test_file_task_handler_running(self, dag_maker):
         def task_callable(ti):
             ti.log.info("test")

Reply via email to