kaxil commented on code in PR #63491:
URL: https://github.com/apache/airflow/pull/63491#discussion_r3740488002
##########
airflow-core/src/airflow/executors/base_executor.py:
##########
@@ -472,6 +491,29 @@ def trigger_tasks(self, open_slots: int) -> None:
if workload_list:
self._process_workloads(workload_list)
+ def trigger_tasks(self, open_slots: int) -> None:
Review Comment:
Following up on my April comment about `trigger_tasks` overrides: the shim
runs the opposite direction from what's needed. It forwards `trigger_tasks` to
`trigger_workloads`, which helps a *caller*, but `heartbeat()` calls
`self.trigger_workloads(open_slots)` directly, so a subclass that *overrides*
`trigger_tasks` is never invoked, and nothing warns.
`order_queued_tasks_by_priority` has the same shape. Before this PR,
`_get_workloads_to_schedule` called `self.order_queued_tasks_by_priority()`, so
an override was honored. Now the sort is inlined and the shim is never called.
Detecting either name in `cls.__dict__` from `__init_subclass__` and
warning, the way the `supports_callbacks` branch does, would at least make the
break loud instead of silent.
##########
airflow-core/src/airflow/executors/base_executor.py:
##########
@@ -210,6 +211,30 @@ def jwt_generator(self) -> JWTGenerator:
return generator
+ def __init_subclass__(cls, **kwargs: Any) -> None:
+ super().__init_subclass__(**kwargs)
+ cls._legacy_warned = set()
+ legacy_flag = cls.__dict__.get("supports_callbacks")
Review Comment:
Two things in this block.
First, `supports_connection_test` doesn't get the treatment
`supports_callbacks` gets. It shipped in 3.3.0 as a public `BaseExecutor`
attribute right alongside `supports_callbacks`, and it's removed here with no
property shim and no `__init_subclass__` branch. A 3.3-era executor declaring
`supports_connection_test = True` still imports fine, but
`supported_workload_types` stays at the default `frozenset({EXECUTE_TASK})`, so
the scheduler fails every connection test routed to it with "Executor 'X' does
not support connection testing", with nothing to explain why.
Second, the synthesis overwrites rather than unions:
```python
cls.supported_workload_types = frozenset({WorkloadType.EXECUTE_TASK,
WorkloadType.EXECUTE_CALLBACK})
```
`LocalExecutor` declares three types including `TEST_CONNECTION`. For `class
MyLocal(LocalExecutor): supports_callbacks = True`,
`"supported_workload_types"` is not in `MyLocal.__dict__` (it's on the parent),
so the guard passes and `TEST_CONNECTION` is dropped from a subclass whose only
change was using the deprecated spelling. `cls.supported_workload_types |
{WorkloadType.EXECUTE_CALLBACK}` would cover that half.
##########
airflow-core/src/airflow/executors/base_executor.py:
##########
@@ -210,6 +211,30 @@ def jwt_generator(self) -> JWTGenerator:
return generator
+ def __init_subclass__(cls, **kwargs: Any) -> None:
+ super().__init_subclass__(**kwargs)
+ cls._legacy_warned = set()
+ legacy_flag = cls.__dict__.get("supports_callbacks")
+ if legacy_flag is True:
+ warnings.warn(
Review Comment:
These warnings won't reach the executors they're aimed at.
`RemovedInAirflow4Warning` subclasses `DeprecationWarning`, and the only
filter re-enabling those is scoped to modules matching `airflow`:
```python
# configuration.py:58
warnings.filterwarnings(action="default", category=DeprecationWarning,
module="airflow")
```
With `stacklevel=2` the warning is attributed to the subclass definition
site, so for an executor in something like `mycompany.executors` it falls
through to CPython's default `ignore::DeprecationWarning`. The newsfragment
says legacy `supports_callbacks` attributes "are still honored ... while
emitting a deprecation warning", but an out-of-tree executor sees nothing
across the whole 3.4 line and then breaks at the 4.0 removal. Deduping once per
class via `_legacy_warned` narrows it further.
Pairing each `warnings.warn` with a `log.warning` would land it in scheduler
logs regardless of filter state.
##########
airflow-core/src/airflow/executors/base_executor.py:
##########
@@ -262,66 +333,45 @@ def log_task_event(self, *, event: str, extra: str,
ti_key: WorkloadKey):
return
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."
+ def queue_workload(self, workload: QueueableWorkload, session: Session) ->
None:
+ if workload.type not in self.supported_workload_types:
+ raise NotImplementedError(
+ f"{type(self).__name__} does not support {workload.type!r}
workloads. "
+ f"Add {workload.type!r} 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]]:
+ def _get_workloads_to_schedule(self, open_slots: int) ->
list[tuple[WorkloadKey, QueueableWorkload]]:
Review Comment:
This changes connection-test admission, so the "no behavioural change"
framing in the description isn't quite accurate.
Before, `heartbeat()` ran two independent budgets:
`trigger_tasks(open_slots)`, then `trigger_connection_tests()` gated on
`slots_available`, which subtracts every queued item. Now there's a single
budget and `TEST_CONNECTION` sorts last.
It diverges in both directions:
- parallelism 32, 40 queued tasks, 1 connection test. Before: 32 tasks
dispatched, then `slots_available = 32 - 0 - 8 - 0 - 1 = 23`, so the test ran
in the same heartbeat. Now it sorts to index 40 and the `[:32]` slice drops it.
Under a sustained backlog it never runs and the reaper times it out.
- parallelism 10, 3 tasks, 20 connection tests. Before: `slots_available =
10 - 0 - 0 - 0 - 20`, so zero tests ran. Now 3 tasks and 7 tests run.
Losing the old over-subscription is an improvement. The ordering is the part
worth another look: connection tests are short and user-interactive, and
`LocalExecutor` is the only in-tree executor supporting them, so a task backlog
starving them is the whole feature. Either move `TEST_CONNECTION` ahead of
`EXECUTE_TASK` in `_workload_type_priority_order` or reserve a small budget for
it, and either way the description needs amending.
##########
airflow-core/src/airflow/executors/base_executor.py:
##########
@@ -223,9 +248,9 @@ def __init__(self, parallelism: int = PARALLELISM,
team_name: str | None = None)
self.parallelism: int = parallelism
self.team_name: str | None = team_name
- self.queued_tasks: dict[TaskInstanceKey, workloads.ExecuteTask] = {}
- self.queued_callbacks: dict[CallbackKey, workloads.ExecuteCallback] =
{}
- self.queued_connection_tests: dict[ConnectionTestKey,
workloads.TestConnection] = {}
+ # TODO(airflow 4.0): flatten to dict[WorkloadKey, QueueableWorkload]
once the deprecated
+ # queued_tasks / queued_callbacks compat properties are removed.
+ self.executor_queues: dict[WorkloadType, dict[WorkloadKey,
QueueableWorkload]] = defaultdict(dict)
Review Comment:
The annotation says `dict` but this is a `defaultdict`, and the difference
is load-bearing. `queue_workload` (`:343`), `fail_connection_test` (`:416`) and
both compat getters index directly and rely on auto-vivification, while
`has_task` (`:385`) defensively uses `.get(..., {})`. That inconsistency inside
one class is the tell.
It leaks downstream. `kubernetes_executor.py:413`'s `if
self.executor_queues:` is permanently truthy after the first task, because the
outer key survives while the inner dict empties, so that debug line now fires
on every `sync()`. `debug_dump` has the inverse problem: iterating only
vivified keys means an idle executor prints no queue lines at all, where before
it always printed the counts.
It also traps anyone migrating off the compat property, since
`self.executor_queues = {}` is legal per the annotation and `KeyError`s on the
next `queue_workload`. Either annotate it as a `defaultdict`/`MutableMapping`
and mention it in the newsfragment, or drop the `defaultdict` and use
`setdefault` at the write sites.
##########
airflow-core/src/airflow/executors/workloads/base.py:
##########
@@ -32,6 +33,32 @@
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.
Review Comment:
Step 3 sends the next author somewhere that doesn't resolve.
`QueueableWorkload` is defined in `workloads/types.py` under `if
TYPE_CHECKING`, isn't imported by `workloads/__init__.py`, and isn't in its
`__all__`, so `airflow.executors.workloads.QueueableWorkload` raises
`AttributeError`. It also isn't a discriminated union, it's a bare `X | Y | Z`,
and `queue_workload` never consults it at runtime, it checks `workload.type not
in self.supported_workload_types`.
The two unions that do gate deserialization aren't mentioned: `All` and
`ExecutorWorkload` in `workloads/__init__.py`, both carrying
`Field(discriminator="type")`. `ExecutorWorkload` is what Celery's
`TypeAdapter` decodes with, so a fourth workload type added by following this
checklist would queue fine and then fail validation on every dequeue, which is
the failure this comment exists to prevent.
Worth asking separately: `ExecutorWorkload` already has identical membership
to the new alias, and `base_executor.py` still uses it in `run_workload`. Is
the second alias earning its keep, or could the queue-facing signatures just
use `ExecutorWorkload`?
##########
airflow-core/src/airflow/executors/workloads/base.py:
##########
@@ -168,3 +195,15 @@ def running_state(self) -> WorkloadState | None:
no intermediate state is emitted.
"""
return None
+
+ @property
+ def sort_key(self) -> int:
Review Comment:
This default lands on `BaseDagBundleWorkload`, but `TestConnection` extends
`BaseWorkloadSchema` directly and so doesn't inherit it. That's why there's a
second copy at `connection_test.py:50`.
`_get_workloads_to_schedule` sorts on `item[1].sort_key` across everything
in the queues, so the next queueable workload following `TestConnection`'s
pattern raises `AttributeError` in the scheduler's sort. Moving the default up
to `BaseWorkloadSchema` drops the duplicate and closes that in one edit.
`sort_key` is arguably a fourth entry for the checklist above too.
##########
providers/edge3/tests/unit/edge3/executors/test_edge_executor.py:
##########
@@ -63,7 +63,7 @@ def get_test_executor(self, pool_slots=1):
ti.dag_run.run_id = key.run_id
ti.dag_run.start_date = datetime(2021, 1, 1)
executor = EdgeExecutor()
- executor.queued_tasks = {key: [None, None, None, ti]}
+ executor.queued_tasks[key] = [None, None, None, ti]
Review Comment:
Not about this line, but it's the only edge3 line in the diff, so it's the
nearest place to raise it.
`edge_executor.py:372` in `revoke_task` still calls
`self.queued_tasks.pop(ti.key, None)`. The byte-identical line got an
`AIRFLOW_V_3_4_PLUS` branch in celery (`celery_executor.py:411`) and
cncf-kubernetes (`kubernetes_executor.py:1001`), but edge3's source wasn't
touched, only this test.
`revoke_task` is a live 3.x path, so on 3.4 Airflow's own bundled provider
emits `RemovedInAirflow4Warning` from the scheduler's revoke path, which is the
noise the once-per-class throttle exists to reduce. `RemovedInAirflow4Warning`
isn't in `forbidden_warnings`, so CI won't catch it either.
(`edge_executor.py:97` is the same pattern but sits in `_process_tasks`, which
is Airflow 2 only.)
##########
providers/amazon/tests/unit/amazon/aws/executors/batch/test_batch_executor.py:
##########
@@ -202,16 +206,19 @@ def test_execute(self, mock_executor):
mock_executor.batch.submit_job.assert_called_once()
assert len(mock_executor.active_workers) == 1
- @pytest.mark.skipif(not AIRFLOW_V_3_0_PLUS, reason="Test requires Airflow
3+")
+ @pytest.mark.skipif(not AIRFLOW_V_3_4_PLUS, reason="Test requires Airflow
3.4+")
Review Comment:
This gate moved from `AIRFLOW_V_3_0_PLUS` to `AIRFLOW_V_3_4_PLUS` in the
same PR that added the pre-3.4 `else` branches these tests were covering, so
the back-compat arm now runs on no CI leg. The provider compatibility matrix
runs unit tests on 2.11.1, 3.0.6, 3.1.8, 3.2.2 and 3.3.0, all above the old
gate and all below the new one.
Same flip in `test_lambda_executor.py:133/186/239`,
`test_batch_executor.py:281/351` and `test_ecs_executor.py:421`. Net effect:
Lambda and Batch have no sub-3.4 coverage of the `del self.queued_*` branches,
ECS keeps only its callback path, and `KubernetesExecutor._process_workloads`
has no test on any version.
This PR already contains the pattern that avoids it:
`test_gauge_executor_metrics` branches the mock target on the version rather
than skipping. Doing that here keeps both arms covered.
Separately, the dispatch half of `TEST_CONNECTION` is unasserted. The
surviving tests cover `queue_workload` accept/reject and the scheduler-side
enqueue, and `test_trigger_connection_tests_skipped_when_not_supported` was
deleted without a replacement, so nothing checks that a queued `TestConnection`
reaches `_process_workloads`.
##########
airflow-core/newsfragments/63491.significant.rst:
##########
@@ -0,0 +1,23 @@
+Deprecate ``BaseExecutor.queued_tasks``, ``queued_callbacks``,
``supports_callbacks``, ``trigger_tasks``, and
``order_queued_tasks_by_priority``
Review Comment:
Two gaps here.
The connection-test half of the refactor isn't listed.
`supports_connection_test`, `queued_connection_tests` and
`trigger_connection_tests()` were all public `BaseExecutor` surface in 3.3.0
and are gone in this PR, so a custom-executor author reading this won't find
the `executor_queues[WorkloadType.TEST_CONNECTION]` and
`supported_workload_types` replacements.
And the `order_queued_tasks_by_priority` line points at
`_get_workloads_to_schedule`, a private method whose return shape differs, as
the shim's own docstring says: the old one returned all queued tasks,
tasks-only and untruncated, while the replacement includes other workload types
and truncates to open slots. Following that line literally means depending on a
private symbol and silently changing behaviour. Either promote a public
replacement or carry the caveat into the note.
--
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]