kaxil commented on code in PR #63491:
URL: https://github.com/apache/airflow/pull/63491#discussion_r4004970210


##########
airflow-core/src/airflow/executors/base_executor.py:
##########
@@ -263,65 +398,44 @@ def log_task_event(self, *, event: str, extra: str, 
ti_key: WorkloadKey):
         self._task_event_logs.append(Log(event=event, task_instance=ti_key, 
extra=extra))
 
     def queue_workload(self, workload: ExecutorWorkload, session: Session) -> 
None:
-        if isinstance(workload, workloads.ExecuteTask):
-            ti = workload.ti
-            self.queued_tasks[ti.key] = workload
-        elif isinstance(workload, workloads.ExecuteCallback):
-            if not self.supports_callbacks:
-                raise NotImplementedError(
-                    f"{type(self).__name__} does not support ExecuteCallback 
workloads. "
-                    f"Set supports_callbacks = True and implement callback 
handling in _process_workloads(). "
-                    f"See LocalExecutor or CeleryExecutor for reference 
implementation."
-                )
-            self.queued_callbacks[workload.key] = workload
-        elif isinstance(workload, workloads.TestConnection):
-            if not self.supports_connection_test:
-                raise NotImplementedError(
-                    f"{type(self).__name__} does not support TestConnection 
workloads. "
-                    f"Set supports_connection_test = True and implement 
connection test handling "
-                    f"in _process_workloads(). See LocalExecutor for reference 
implementation."
-                )
-            self.queued_connection_tests[workload.key] = workload
-        else:
-            raise ValueError(
-                f"Un-handled workload type {type(workload).__name__!r} in 
{type(self).__name__}. "
-                f"Workload must be one of: ExecuteTask, ExecuteCallback, 
TestConnection."
+        if workload.type not in self.supported_workload_types:
+            raise NotImplementedError(
+                f"{type(self).__name__} does not support {workload.type.value} 
workloads. "
+                f"Add WorkloadType.{workload.type.name} to 
supported_workload_types and implement handling "
+                f"in _process_workloads()."
             )
+        self.executor_queues[workload.type][workload.key] = workload
 
     def _get_workloads_to_schedule(self, open_slots: int) -> 
list[tuple[WorkloadKey, ExecutorWorkload]]:
         """
         Select and return the next batch of workloads to schedule, respecting 
priority policy.
 
-        Priority Policy: Callbacks are scheduled before tasks (callbacks 
complete existing work).
-        Callbacks are processed in FIFO order. Tasks are sorted by 
priority_weight (higher priority first).
+        Workloads are sorted by ``WORKLOAD_TYPE_PRIORITY`` (priority assigned 
by workload type) first,
+        then by ``sort_key`` within the same priority.  Lower priority values 
are scheduled first;
+        within the same priority, lower ``sort_key`` values come first 
(``sort_key=0`` gives FIFO).
 
         :param open_slots: Number of available execution slots
         """
-        workloads_to_schedule: list[tuple[WorkloadKey, ExecutorWorkload]] = []
-
-        if self.queued_callbacks:
-            for key, workload in self.queued_callbacks.items():
-                if len(workloads_to_schedule) >= open_slots:
-                    break
-                workloads_to_schedule.append((key, workload))
-
-        if open_slots > len(workloads_to_schedule) and self.queued_tasks:
-            for task_key, task_workload in 
self.order_queued_tasks_by_priority():
-                if len(workloads_to_schedule) >= open_slots:
-                    break
-                workloads_to_schedule.append((task_key, task_workload))
-
-        return workloads_to_schedule
+        all_workloads: list[tuple[WorkloadKey, ExecutorWorkload]] = [
+            (key, workload) for queue in self.executor_queues.values() for 
key, workload in queue.items()
+        ]
+        all_workloads.sort(
+            key=lambda item: (
+                workloads.WORKLOAD_TYPE_PRIORITY.get(item[1].type, 
len(workloads.WORKLOAD_TYPE_PRIORITY)),
+                item[1].sort_key,
+            )
+        )
+        return all_workloads[: max(0, open_slots)]
 
-    def _process_workloads(self, workload_items: Sequence[ExecutorWorkload]) 
-> None:
+    def _process_workloads(self, workloads: Sequence[ExecutorWorkload]) -> 
None:

Review Comment:
   Renaming this parameter off `workload_items` breaks keyword calls into a 
documented extension point (`core-concepts/executor/index.rst:313` lists 
`_process_workloads` as one), since 3.3.1 and the merge base both named it 
`workload_items` and an out-of-tree 
`super()._process_workloads(workload_items=...)` now raises `TypeError`. It 
also shadows the module-level `workloads` import that 
`_get_workloads_to_schedule` uses just above, and splits the name three ways 
in-tree while celery, ecs, batch and lambda all stay on `workload_items`.



##########
airflow-core/tests/unit/executors/test_base_executor.py:
##########
@@ -773,6 +910,181 @@ def test_get_workloads_prioritizes_callbacks(self, 
dag_maker, session):
         assert isinstance(first_workload, workloads.ExecuteCallback)  # Assert 
callback comes first
 
 
+class TestBackwardCompatProperties:
+    """Tests for the backward-compat properties (queued_tasks, 
queued_callbacks, supports_callbacks)."""
+
+    @pytest.fixture(autouse=True)
+    def _reset_legacy_warned(self):
+        BaseExecutor._legacy_warned = set()
+        yield
+        BaseExecutor._legacy_warned = set()
+
+    def test_queued_tasks_delegates_to_executor_queues(self):
+        executor = BaseExecutor()
+        executor.executor_queues[WorkloadType.EXECUTE_TASK]["key1"] = 
"workload1"
+
+        with pytest.warns(DeprecationWarning, match="queued_tasks is 
deprecated"):
+            result = executor.queued_tasks
+
+        assert result is executor.executor_queues[WorkloadType.EXECUTE_TASK]
+        assert "key1" in result
+
+    def test_queued_callbacks_delegates_to_executor_queues(self):
+        executor = BaseExecutor()
+        executor.executor_queues[WorkloadType.EXECUTE_CALLBACK]["cb1"] = 
"callback1"
+
+        with pytest.warns(DeprecationWarning, match="queued_callbacks is 
deprecated"):
+            result = executor.queued_callbacks
+
+        assert result is 
executor.executor_queues[WorkloadType.EXECUTE_CALLBACK]
+        assert "cb1" in result
+
+    def test_supports_callbacks_delegates_to_supported_workload_types(self):
+        executor = BaseExecutor()
+
+        with pytest.warns(DeprecationWarning, match="supports_callbacks is 
deprecated"):
+            assert executor.supports_callbacks is False
+
+        executor.supported_workload_types = frozenset(
+            {WorkloadType.EXECUTE_TASK, WorkloadType.EXECUTE_CALLBACK}
+        )
+        with warnings.catch_warnings():
+            warnings.simplefilter("error", RemovedInAirflow4Warning)
+            assert executor.supports_callbacks is True
+
+    def 
test_supports_connection_test_delegates_to_supported_workload_types(self):
+        executor = BaseExecutor()
+
+        with pytest.warns(DeprecationWarning, match="supports_connection_test 
is deprecated"):
+            assert executor.supports_connection_test is False
+
+        executor.supported_workload_types = frozenset(
+            {WorkloadType.EXECUTE_TASK, WorkloadType.TEST_CONNECTION}
+        )
+        with warnings.catch_warnings():
+            warnings.simplefilter("error", RemovedInAirflow4Warning)
+            assert executor.supports_connection_test is True
+
+    def test_warning_emitted_once_per_class(self, recwarn):
+        executor = BaseExecutor()
+        for _ in range(5):
+            _ = executor.queued_tasks
+        legacy = [w for w in recwarn.list if "queued_tasks is deprecated" in 
str(w.message)]
+        assert len(legacy) == 1
+
+    def test_warning_independent_per_subclass(self, recwarn):
+        class ExecutorA(BaseExecutor):
+            pass
+
+        class ExecutorB(BaseExecutor):
+            pass
+
+        _ = ExecutorA().queued_tasks
+        _ = ExecutorA().queued_tasks
+        _ = ExecutorB().queued_tasks
+        legacy = [w for w in recwarn.list if "queued_tasks is deprecated" in 
str(w.message)]
+        assert len(legacy) == 2
+
+    def test_queued_tasks_dict_operations(self):
+        """Verify dict operations through the backward-compat property work 
correctly."""
+        executor = BaseExecutor()
+        executor.executor_queues[WorkloadType.EXECUTE_TASK]["k1"] = "w1"
+        executor.executor_queues[WorkloadType.EXECUTE_TASK]["k2"] = "w2"
+
+        with pytest.warns(DeprecationWarning, match="queued_tasks is 
deprecated"):
+            qt = executor.queued_tasks
+
+        # All standard dict operations should work on the returned reference
+        assert len(qt) == 2
+        assert "k1" in qt
+        qt.pop("k1")
+        assert len(executor.executor_queues[WorkloadType.EXECUTE_TASK]) == 1
+
+
+class TestLegacySupportsCallbacksShim:
+    """Subclasses declaring legacy ``supports_callbacks = True`` must still 
receive callbacks."""
+
+    def test_legacy_flag_synthesises_supported_workload_types(self):
+        with pytest.warns(RemovedInAirflow4Warning, match="supports_callbacks 
= True"):
+
+            class LegacyExecutor(BaseExecutor):
+                supports_callbacks = True
+
+        assert LegacyExecutor.supported_workload_types == frozenset(
+            {WorkloadType.EXECUTE_TASK, WorkloadType.EXECUTE_CALLBACK}
+        )
+
+    def test_explicit_supported_workload_types_wins(self):
+        explicit = frozenset({WorkloadType.EXECUTE_TASK})
+        with pytest.warns(RemovedInAirflow4Warning, match="supports_callbacks 
= True"):
+
+            class MixedExecutor(BaseExecutor):
+                supports_callbacks = True
+                supported_workload_types = explicit
+
+        assert MixedExecutor.supported_workload_types is explicit
+
+    def test_modern_subclass_emits_no_warning(self, recwarn):
+        class ModernExecutor(BaseExecutor):
+            supported_workload_types = frozenset({WorkloadType.EXECUTE_TASK, 
WorkloadType.EXECUTE_CALLBACK})
+
+        legacy_warnings = [w for w in recwarn.list if "supports_callbacks = 
True" in str(w.message)]
+        assert legacy_warnings == []
+        assert WorkloadType.EXECUTE_CALLBACK in 
ModernExecutor.supported_workload_types
+
+    def test_legacy_false_does_not_synthesise(self, recwarn):
+        class OptedOutExecutor(BaseExecutor):
+            supports_callbacks = False
+
+        legacy_warnings = [w for w in recwarn.list if "supports_callbacks = 
True" in str(w.message)]
+        assert legacy_warnings == []
+        assert WorkloadType.EXECUTE_CALLBACK not in 
OptedOutExecutor.supported_workload_types

Review Comment:
   This passes for the wrong reason. `OptedOutExecutor` derives from bare 
`BaseExecutor`, whose default `supported_workload_types` never held 
`EXECUTE_CALLBACK`, so the assertion holds even with `__init_subclass__` 
deleted outright. `test_legacy_flag_unions_with_inherited_workload_types` below 
picks `LocalExecutor` as the parent for exactly this reason, and the `= False` 
case needs that same parent before it binds to anything.



##########
airflow-core/src/airflow/executors/base_executor.py:
##########
@@ -210,6 +216,47 @@ def jwt_generator(self) -> JWTGenerator:
 
         return generator
 
+    def __init_subclass__(cls, **kwargs: Any) -> None:
+        super().__init_subclass__(**kwargs)
+        cls._legacy_warned = set()
+        legacy_workload_types: set[WorkloadType] = set()
+        if cls.__dict__.get("supports_callbacks") is True:
+            _warn_deprecated_executor_usage(
+                f"{cls.__name__}: setting `supports_callbacks = True` as a 
class attribute is "
+                f"deprecated. Add `WorkloadType.EXECUTE_CALLBACK` to 
`supported_workload_types` "
+                f"instead.",
+            )
+            legacy_workload_types.add(WorkloadType.EXECUTE_CALLBACK)
+        if cls.__dict__.get("supports_connection_test") is True:

Review Comment:
   Three unwarned breaks fall out of turning these two released attributes into 
properties. Class-level reads flip: `BaseExecutor.supports_callbacks` is now a 
truthy `property` object where 3.3.1 gave a plain `False` 
(`base_executor.py:172` at that tag), and `property.__get__(None, owner)` never 
runs the getter, so `if executor_cls.supports_callbacks:` inverts with no 
warning at all. The `= False` opt-out is dropped, since this check only matches 
`is True` while 3.3.1's `LocalExecutor` declared both flags `True`, so `class 
Restricted(LocalExecutor): supports_connection_test = False` keeps 
TEST_CONNECTION in the inherited frozenset and `scheduler_job_runner.py:4098` 
now dispatches the connection tests 3.3.x refused with "does not support 
connection testing". And the setters added last round never fire for exactly 
those subclasses: a retained legacy bool sits earlier in the MRO, so 
`self.supports_callbacks = X` writes a plain instance attribute, and a 
legacy-`False` subclass enabling a
 t runtime reads back `True` while `supported_workload_types` stays 
`{EXECUTE_TASK}` and `queue_workload` then raises. A descriptor whose `__get__` 
resolves from `owner.supported_workload_types` when `obj is None`, plus 
handling `is False` here and dropping the legacy attribute once it is folded 
in, closes all three.



##########
airflow-core/newsfragments/63491.significant.rst:
##########
@@ -0,0 +1,52 @@
+Unify executor workload queues under ``BaseExecutor.executor_queues``
+
+Executor workload state is now stored on the unified 
``BaseExecutor.executor_queues`` mapping
+keyed by ``WorkloadType``, and scheduling is driven by ``trigger_workloads``. 
The previous
+per-type attributes and entrypoints ``queued_tasks``, ``queued_callbacks``, 
``supports_callbacks``,
+``trigger_tasks``, and ``order_queued_tasks_by_priority`` are kept as 
backward-compatible shims
+that emit ``RemovedInAirflow4Warning`` and will be removed in Airflow 4.0.
+
+The connection-test surface introduced in Airflow 3.3 
(``supports_connection_test``,
+``queued_connection_tests``, and ``trigger_connection_tests()``) is folded 
into the same
+mechanism: connection tests are queued in 
``executor_queues[WorkloadType.TEST_CONNECTION]``
+and dispatched by ``trigger_workloads`` alongside other workload types. A 
read-only
+``supports_connection_test`` compat property remains; 
``queued_connection_tests`` and

Review Comment:
   Two bits of this drifted from the latest commit. This line still calls 
`supports_connection_test` read-only, but `@supports_callbacks.setter` and 
`@supports_connection_test.setter` landed in the same push, so instance 
assignment works again. Line 47 also still names only `trigger_tasks` and 
`order_queued_tasks_by_priority` as the overrides that warn at class-definition 
time, while `trigger_connection_tests` joined `legacy_override_replacements` in 
that same commit.



##########
airflow-core/src/airflow/executors/workloads/base.py:
##########
@@ -32,6 +33,42 @@
     from airflow.executors.workloads.types import WorkloadState
 
 
+class WorkloadType(str, Enum):
+    """Central registry of executor workload types."""
+
+    EXECUTE_TASK = "ExecuteTask"
+    EXECUTE_CALLBACK = "ExecuteCallback"
+    TEST_CONNECTION = "TestConnection"
+
+
+# Central executor priority registry: tuple is ordered from highest priority 
to lowest.
+#
+# Connection tests are short-lived and user-interactive, so they sort ahead of 
tasks:
+# otherwise a sustained task backlog would starve them until the reaper times 
them out.
+#
+# Adding a new workload type is a six-place change that must stay in sync:

Review Comment:
   The checklist has come up short twice now, so rather than a seventh and 
eighth bullet, here is the closed set, derived from the tree rather than by 
reading it.
   
   `WorkloadType` has three members, and four parallel three-member families 
track it: the schemas (`ExecuteTask`, `ExecuteCallback`, `TestConnection`), the 
keys (`TaskInstanceKey`, `CallbackKey`, `ConnectionTestKey`), the states 
(`TaskInstanceState`, `CallbackState`, `ConnectionTestState`), and the ORM 
models (`TaskInstance`, `ExecutorCallback`, `ConnectionTestRequest`). Adding a 
fourth workload type touches ten places: the enum, 
`_workload_type_priority_order`, the five aliases (`All` and `ExecutorWorkload` 
in `workloads/__init__.py`; `WorkloadKey`, `WorkloadState` and 
`SchedulerWorkload` in `workloads/types.py`), and three dispatch sites.
   
   The dispatch sites are `BaseExecutor.run_workload`, ending in `raise 
ValueError` at `base_executor.py:845`; `state_class_for_key`, ending in `raise 
TypeError`; and `SchedulerJobRunner.process_executor_events`, whose key-type 
chain ends at `scheduler_job_runner.py:1446` in `cls.logger().error("Unknown 
workload key type in event buffer: %r", key)`.
   
   That last one is worth fixing whatever you decide about the checklist, 
because it is the only one of the three that fails quietly: a fourth type would 
queue, dispatch and run, then lose its terminal events to a log line while both 
sibling sites raise. (`_process_executor_events` at :1349 is only the wrapper; 
the chain lives in the classmethod.)
   
   Since the list has been wrong twice, a test asserting each family's 
membership equals `set(WorkloadType)` would keep this honest without anyone 
maintaining prose.



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]

Reply via email to