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

o-nikolas 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 0abd1616919 Run team tasks on the team executor when named by class or 
path (#75362)
0abd1616919 is described below

commit 0abd1616919bd23ac16c872cdbb5012ebfe58645
Author: Niko Oliveira <[email protected]>
AuthorDate: Thu Oct 22 16:21:34 2026 -0700

    Run team tasks on the team executor when named by class or path (#75362)
    
    With multi-team enabled, a team task that names its executor by class
    name or module path (e.g. executor="CeleryExecutor") is documented to
    run on its team's executor of that class. Only the global alias should
    reach the global executor ("Aliasing Executors Across Teams" in the
    multi-team docs).
    
    The scheduler took the first matching executor instead, and global
    executors are always listed before team ones. So these tasks silently
    ran on the shared global executor, outside the team boundary, and
    anything resolving executors the documented way (such as reading live
    task logs) looked in the wrong place.
---
 .../src/airflow/jobs/scheduler_job_runner.py       | 48 +++++-------
 airflow-core/tests/unit/jobs/test_scheduler_job.py | 85 +++++++++++++++++++---
 2 files changed, 94 insertions(+), 39 deletions(-)

diff --git a/airflow-core/src/airflow/jobs/scheduler_job_runner.py 
b/airflow-core/src/airflow/jobs/scheduler_job_runner.py
index 5a56f7bfc33..198ef4bdc0a 100644
--- a/airflow-core/src/airflow/jobs/scheduler_job_runner.py
+++ b/airflow-core/src/airflow/jobs/scheduler_job_runner.py
@@ -71,7 +71,7 @@ from airflow.callbacks.callback_requests import (
 )
 from airflow.configuration import conf
 from airflow.dag_processing.bundles.base import BundleUsageTrackingManager
-from airflow.exceptions import DagNotFound, TaskNotFound
+from airflow.exceptions import DagNotFound, TaskNotFound, 
UnknownExecutorException
 from airflow.executors import workloads
 from airflow.executors.executor_loader import ExecutorLoader
 from airflow.executors.workloads.types import TaskInstanceUuid
@@ -4560,40 +4560,32 @@ class SchedulerJobRunner(BaseJobRunner, LoggingMixin):
         :param team_name: Optional pre-resolved team name. If NOTSET and 
multi-team is enabled,
                          will query the database to resolve team name. None 
indicates global team.
         """
-        executor = None
         if self._multi_team:
             # Use provided team_name if available, otherwise query the database
             if team_name is NOTSET:
                 team_name = self._get_workload_team_name(workload, session)
         else:
             team_name = None
-        # If there is no executor set on the workload fetch the default 
(either globally or for the team)
-        if workload.get_executor_name() is None:
-            if not team_name:
-                # No team is specified, use the global default executor
-                executor = self.executor
-            else:
-                # We do have a team, use the default executor for that team
-                for _executor in self.executors:
-                    # First executor that resolves should be the default for 
that team
-                    if _executor.team_name == team_name:
-                        executor = _executor
-                        break
-                else:
-                    # No executor found for that team, fall back to global 
default
-                    executor = self.executor
+
+        executor: BaseExecutor | None
+        executor_name = workload.get_executor_name()
+        if executor_name is None and not team_name:
+            # No team and no executor named on the workload, so it's always 
the global default.
+            executor = self.executor
         else:
-            # An executor is specified on the workload (as a str), so we need 
to find it in the list of executors
-            for _executor in self.executors:
-                if _executor.name and workload.get_executor_name() in (
-                    _executor.name.alias,
-                    _executor.name.module_path,
-                    _executor.name.module_path.split(".")[-1],
-                ):
-                    # The executor must either match the team or be global 
(i.e. team_name is None)
-                    if team_name and _executor.team_name == team_name or 
_executor.team_name is None:
-                        executor = _executor
-                        break
+            # The resolver below only sees names, so keep a mapping from each 
name to its executor.
+            executors_by_name: dict[ExecutorName, BaseExecutor] = {
+                _executor.name: _executor for _executor in self.executors if 
_executor.name
+            }
+            try:
+                resolved_name = ExecutorLoader.resolve_executor_name(
+                    executor_name, team_name, executor_names=executors_by_name
+                )
+            except UnknownExecutorException:
+                # The name matches nothing this team may use, e.g. another 
team's executor. Warned below.
+                executor = None
+            else:
+                executor = executors_by_name[resolved_name]
 
         if executor is not None:
             self.log.debug(
diff --git a/airflow-core/tests/unit/jobs/test_scheduler_job.py 
b/airflow-core/tests/unit/jobs/test_scheduler_job.py
index 84bb06cc768..9f7d83b9787 100644
--- a/airflow-core/tests/unit/jobs/test_scheduler_job.py
+++ b/airflow-core/tests/unit/jobs/test_scheduler_job.py
@@ -363,6 +363,12 @@ def make_pool_stats(
     }
 
 
+def assign_executor_to_team(executor: MagicMock, team_name: str) -> None:
+    # ExecutorLoader.load_executor sets the team on both; the scheduler reads 
it from the name.
+    executor.team_name = team_name
+    executor.name.team_name = team_name
+
+
 @patch.dict(
     ExecutorLoader.executors, {MOCK_EXECUTOR: 
f"{MockExecutor.__module__}.{MockExecutor.__qualname__}"}
 )
@@ -2392,8 +2398,8 @@ class TestSchedulerJob:
         session.add_all([bundle1, bundle2])
         session.flush()
 
-        mock_executors[0].team_name = "team_a"
-        mock_executors[1].team_name = "team_b"
+        assign_executor_to_team(mock_executors[0], "team_a")
+        assign_executor_to_team(mock_executors[1], "team_b")
 
         with dag_maker(dag_id="dag_a", bundle_name="bundle_a", 
session=session):
             op1 = EmptyOperator(task_id="task_a_default")  # No explicit 
executor - should use team's default
@@ -4653,6 +4659,8 @@ class TestSchedulerJob:
         dag_run = dag_maker.create_dagrun(run_id="test_run", 
state=DagRunState.RUNNING)
 
         mock_executor = MagicMock()
+        mock_executor.name = 
ExecutorName(module_path="mock.executor.module.path")
+        mock_executor.team_name = None
         scheduler_job = Job()
 
         ti = dag_run.get_task_instance(task_id="task", session=session)
@@ -11950,7 +11958,7 @@ class TestSchedulerJob:
         session.flush()
 
         # Configure one executor to be team-specific
-        mock_executors[1].team_name = "team_a"
+        assign_executor_to_team(mock_executors[1], "team_a")
 
         with dag_maker(dag_id="dag_a", bundle_name="bundle_a", 
session=session):
             task = EmptyOperator(task_id="test_task")  # No explicit executor
@@ -12016,7 +12024,7 @@ class TestSchedulerJob:
         session.flush()
 
         # Configure executor for the team
-        mock_executors[1].team_name = "team_a"
+        assign_executor_to_team(mock_executors[1], "team_a")
 
         with dag_maker(dag_id="dag_a", bundle_name="bundle_a", 
session=session):
             task = EmptyOperator(task_id="test_task", 
executor="secondary_exec")
@@ -12050,7 +12058,7 @@ class TestSchedulerJob:
         session.flush()
 
         # Configure one executor for the team, but keep default as global
-        mock_executors[1].team_name = "team_a"
+        assign_executor_to_team(mock_executors[1], "team_a")
 
         with dag_maker(dag_id="dag_a", bundle_name="bundle_a", 
session=session):
             task = EmptyOperator(task_id="test_task", executor="default_exec") 
 # Global executor
@@ -12085,7 +12093,7 @@ class TestSchedulerJob:
         session.flush()
 
         # Configure executors for different teams
-        mock_executors[1].team_name = "team_b"  # Different team!
+        assign_executor_to_team(mock_executors[1], "team_b")  # Different team!
 
         with dag_maker(dag_id="dag_a", bundle_name="bundle_a", 
session=session):  # DAG belongs to team_a
             task = EmptyOperator(
@@ -12144,7 +12152,7 @@ class TestSchedulerJob:
         session.add(bundle)
         session.flush()
 
-        mock_executors[1].team_name = "team_a"
+        assign_executor_to_team(mock_executors[1], "team_a")
 
         with dag_maker(dag_id="dag_a", bundle_name="bundle_a", 
session=session):
             task = EmptyOperator(task_id="test_task")
@@ -12193,6 +12201,61 @@ class TestSchedulerJob:
         # Should match by classname (last component of module_path) and return 
the global executor
         assert result == mock_executors[0]
 
+    @conf_vars({("core", "multi_team"): "true"})
+    @pytest.mark.parametrize(
+        ("task_executor", "expected_index"),
+        [
+            pytest.param("CeleryExecutor", 1, id="classname-resolves-to-team"),
+            pytest.param(
+                
"airflow.providers.celery.executors.celery_executor.CeleryExecutor",
+                1,
+                id="module-path-resolves-to-team",
+            ),
+            pytest.param("team_celery_exec", 1, id="team-alias"),
+            pytest.param("global_celery_exec", 0, id="global-alias"),
+        ],
+    )
+    def 
test_multi_team_try_to_load_executor_prefers_team_over_global_with_same_name(
+        self, dag_maker, mock_executors, session, task_executor, expected_index
+    ):
+        """A team task naming an executor that exists both globally and for 
its team gets the team one.
+
+        Mirrors 
``global_celery_exec:CeleryExecutor;team_a=team_celery_exec:CeleryExecutor`` 
from the
+        multi-team docs. Global executors come first in ``self.executors``, so 
only the global alias
+        should reach the global executor.
+        """
+        clear_db_teams()
+        clear_db_dag_bundles()
+
+        team = Team(name="team_a")
+        session.add(team)
+        session.flush()
+
+        bundle = DagBundleModel(name="bundle_a")
+        bundle.teams.append(team)
+        session.add(bundle)
+        session.flush()
+
+        celery_module_path = 
"airflow.providers.celery.executors.celery_executor.CeleryExecutor"
+        mock_executors[0].name = ExecutorName(alias="global_celery_exec", 
module_path=celery_module_path)
+        mock_executors[1].name = ExecutorName(
+            alias="team_celery_exec", module_path=celery_module_path, 
team_name="team_a"
+        )
+        mock_executors[1].team_name = "team_a"
+
+        with dag_maker(dag_id="dag_a", bundle_name="bundle_a", 
session=session):
+            task = EmptyOperator(task_id="test_task", executor=task_executor)
+
+        dr = dag_maker.create_dagrun()
+        ti = dr.get_task_instance(task.task_id, session=session)
+
+        scheduler_job = Job()
+        self.job_runner = SchedulerJobRunner(job=scheduler_job)
+
+        result = self.job_runner._try_to_load_executor(ti, session=session)
+
+        assert result == mock_executors[expected_index]
+
     @conf_vars({("core", "multi_team"): "true"})
     def test_multi_team_scheduling_loop_batch_optimization(self, dag_maker, 
mock_executors, session):
         """Test that the scheduling loop uses batch team resolution 
optimization."""
@@ -12211,8 +12274,8 @@ class TestSchedulerJob:
         session.add_all([bundle1, bundle2])
         session.flush()
 
-        mock_executors[0].team_name = "team_a"
-        mock_executors[1].team_name = "team_b"
+        assign_executor_to_team(mock_executors[0], "team_a")
+        assign_executor_to_team(mock_executors[1], "team_b")
 
         with dag_maker(dag_id="dag_a", bundle_name="bundle_a", 
session=session):
             EmptyOperator(task_id="task_a")
@@ -12261,8 +12324,8 @@ class TestSchedulerJob:
         session.add_all([bundle1, bundle2])
         session.flush()
 
-        mock_executors[0].team_name = "team_a"
-        mock_executors[1].team_name = "team_b"
+        assign_executor_to_team(mock_executors[0], "team_a")
+        assign_executor_to_team(mock_executors[1], "team_b")
 
         with dag_maker(dag_id="dag_a", bundle_name="bundle_a", 
session=session):
             EmptyOperator(task_id="task_a")

Reply via email to