Skip to content

Commit 2e95945

Browse files
committed
Enable trigger queue support for async callbacks
1 parent 9d3f89c commit 2e95945

10 files changed

Lines changed: 147 additions & 27 deletions

File tree

airflow-core/docs/authoring-and-scheduling/deferring.rst

Lines changed: 26 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -493,6 +493,8 @@ According to `benchmarks <https://github.com/apache/airflow/pull/58803#pullreque
493493

494494
You can determine a suitable value for your deployment by creating a large number of triggers (for example, by triggering a Dag with many deferrable tasks) and observing both how the load is distributed across Triggerers in your environment and how long it takes for all Triggerers to pick up the triggers.
495495

496+
.. _deferring/triggerer_queue_assignment:
497+
496498
Controlling Triggerer Host Assignment Per Trigger
497499
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
498500

@@ -508,7 +510,7 @@ Under some circumstances, it may be desirable to assign a Trigger to a specific
508510
If you are using :doc:`Multi-Team mode</core-concepts/multi-team>`, the ``--team-name`` option provides
509511
native team-scoped triggerer assignment for all trigger types (task-created, event-driven, and callback).
510512
See :ref:`Team-scoped Triggerer <multi-team-triggerer>` in the Multi-Team documentation.
511-
The ``--queues`` option described below is an older, queue-based mechanism that can be combined with
513+
The ``--queues`` option described below is a distinct queue-based mechanism which may be combined with
512514
``--team-name`` if needed.
513515

514516
To enable queue assignment for triggers, do the following:
@@ -536,33 +538,35 @@ This feature is only compatible with executors which utilize the task ``queue``
536538
task-created triggers. :doc:`Event-Driven Triggers<../authoring-and-scheduling/event-scheduling>`
537539
are not tied to a task queue, but a :class:`~airflow.triggers.base.BaseEventTrigger` subclass can
538540
be assigned to a queue explicitly by passing ``queue=`` to ``super().__init__()``, independently
539-
of any executor.
540-
541-
Queue assignment is not supported for triggers from async
542-
:doc:`Callbacks<../administration-and-deployment/logging-monitoring/callbacks>`.
543-
544-
+------------------------------------------------------------------------------------------------------+----------------------+----------------------------------------------------------------------------------+
545-
| Trigger Type | Supports queues? | Triggerer assignment when :ref:`config:triggerer__queues_enabled` is ``True`` |
546-
+======================================================================================================+======================+==================================================================================+
547-
| Task-created Trigger instances | Yes | Any triggerer with the task queue present in its ``--queues`` option |
548-
+------------------------------------------------------------------------------------------------------+----------------------+----------------------------------------------------------------------------------+
549-
| :doc:`Event-Driven Triggers<../authoring-and-scheduling/event-scheduling>` | Yes | Any triggerer whose ``--queues`` includes the trigger's ``queue``, or any |
550-
| | | triggerer without ``--queues`` if no ``queue`` was set |
551-
+------------------------------------------------------------------------------------------------------+----------------------+----------------------------------------------------------------------------------+
552-
| Triggers from async :doc:`Callbacks<../administration-and-deployment/logging-monitoring/callbacks>` | No | Any triggerer running without the ``--queues`` option |
553-
+------------------------------------------------------------------------------------------------------+----------------------+----------------------------------------------------------------------------------+
554-
555-
If you use queues for task-based and/or event-driven triggers, while **also** using callback triggers
556-
(or event-driven triggers without an explicit ``queue``), you must run one or more triggerer hosts
541+
of any executor. Similarly, triggers from async
542+
:doc:`Callbacks<../administration-and-deployment/logging-monitoring/callbacks>` can be assigned to a
543+
queue by passing ``queue=`` to :class:`~airflow.sdk.AsyncCallback`.
544+
545+
+------------------------------------------------------------------------------------------------------+-----------------------------------------------------------------------------------------+
546+
| Trigger Type | Triggerer assignment source when :ref:`config:triggerer__queues_enabled` is ``True`` |
547+
+======================================================================================================+=========================================================================================+
548+
| Task-created Trigger instances | Any triggerer with the task queue present in its ``--queues`` option |
549+
+------------------------------------------------------------------------------------------------------+-----------------------------------------------------------------------------------------+
550+
| :doc:`Event-Driven Triggers<../authoring-and-scheduling/event-scheduling>` | Any triggerer whose ``--queues`` includes the trigger's ``queue``, or any |
551+
| | triggerer without ``--queues`` if no ``queue`` was set |
552+
+------------------------------------------------------------------------------------------------------+-----------------------------------------------------------------------------------------+
553+
| Triggers from async :doc:`Callbacks<../administration-and-deployment/logging-monitoring/callbacks>` | Any triggerer whose ``--queues`` includes the callback's ``queue``, or any |
554+
| | triggerer without ``--queues`` if no ``queue`` was set |
555+
+------------------------------------------------------------------------------------------------------+-----------------------------------------------------------------------------------------+
556+
557+
If you run a mix of triggers with queues **and** without queues assigned, (of any trigger type), you must run at least one triggerer host
557558
**without** the ``--queues`` option, so the latter are still run.
558559

559560
.. note::
560561
To enable trigger queues, you must set the ``--queues`` option on one or more triggerers' startup command (these values may differ between the various triggerers).
561-
If you set the ``--queue`` value of a triggerer to some value which no task queues or event-driven trigger queues exist for, that triggerer will never run any triggers.
562-
Similarly, all ``triggerer`` instances running without the ``--queues`` option will only consume callback-based triggers and event-driven triggers that were not assigned an explicit ``queue``.
562+
If you set the ``--queue`` value of a triggerer to some value which no triggers are assigned (either from their task queue, or from their explicit ``queue`` field), then that triggerer
563+
will never run any triggers. Similarly, all triggerer instances running without the ``--queues`` option will only consume callback-based triggers and event-driven triggers that were not
564+
assigned an explicit ``queue``.
563565

564566
An event-driven trigger's ``queue`` is set once, when the trigger is registered for an
565-
:class:`~airflow.sdk.AssetWatcher` during Dag processing, and is independent of
567+
:class:`~airflow.sdk.AssetWatcher` during Dag processing. A callback trigger's ``queue`` is set
568+
from the ``queue`` passed to :class:`~airflow.sdk.AsyncCallback` when the deadline is missed and the
569+
callback is queued on the Triggerer. Both are independent of
566570
:doc:`Multi-Team mode</core-concepts/multi-team>`'s ``team_name`` scoping — the two can be
567571
combined.
568572

airflow-core/docs/howto/deadline-alerts.rst

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -362,6 +362,21 @@ A **custom asynchronous callback** might look like this:
362362
):
363363
EmptyOperator(task_id="example_task")
364364
365+
.. tip::
366+
``AsyncCallback`` accepts an optional ``queue`` parameter to assign the resulting trigger to a
367+
specific :ref:`triggerer queue <config:triggerer__queues_enabled>`. If not specified, the
368+
callback trigger runs on any triggerer started without the ``--queues`` option. See
369+
:ref:`Controlling Triggerer Host Assignment Per Trigger <deferring/triggerer_queue_assignment>`
370+
for details.
371+
372+
.. code-block:: python
373+
374+
AsyncCallback(
375+
my_callback,
376+
kwargs={"msg": "deadline missed"},
377+
queue="alerts",
378+
)
379+
365380
Templating and Context
366381
^^^^^^^^^^^^^^^^^^^^^^
367382

airflow-core/src/airflow/models/callback.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,13 @@ class ImportPathExecutorCallbackDefProtocol(ImportPathCallbackDefProtocol, Proto
102102
executor: str | None
103103

104104

105+
@runtime_checkable
106+
class ImportPathAsyncCallbackDefProtocol(ImportPathCallbackDefProtocol, Protocol):
107+
"""Protocol for callbacks that use the import path fetch method and support triggerer queue assignment."""
108+
109+
queue: str | None
110+
111+
105112
class Callback(Base, BaseWorkload):
106113
"""Base class for callbacks."""
107114

@@ -201,7 +208,7 @@ def create_from_sdk_def(callback_def: CallbackDefinitionProtocol, **kwargs) -> C
201208
match type(callback_def).__name__:
202209
case "AsyncCallback":
203210
if TYPE_CHECKING:
204-
assert isinstance(callback_def, ImportPathCallbackDefProtocol)
211+
assert isinstance(callback_def, ImportPathAsyncCallbackDefProtocol)
205212
return TriggererCallback(callback_def, **kwargs)
206213

207214
case "SyncCallback":
@@ -244,6 +251,7 @@ def queue(self, *, session: Session) -> None:
244251
CallbackTrigger(
245252
callback_path=self.data["path"],
246253
callback_kwargs=self.data["kwargs"],
254+
queue=self.data.get("queue"),
247255
)
248256
)
249257
self.trigger.team_name = team_name

airflow-core/src/airflow/models/trigger.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -200,7 +200,7 @@ def rotate_fernet_key(self):
200200
def from_object(cls, trigger: BaseTrigger) -> Trigger:
201201
"""Alternative constructor that creates a trigger row based directly off of a Trigger object."""
202202
classpath, kwargs = trigger.serialize()
203-
return cls(classpath=classpath, kwargs=kwargs)
203+
return cls(classpath=classpath, kwargs=kwargs, queue=getattr(trigger, "queue", None))
204204

205205
@classmethod
206206
@provide_session

airflow-core/src/airflow/triggers/callback.py

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,12 +35,20 @@
3535
class CallbackTrigger(BaseTrigger):
3636
"""Trigger that executes a callback function asynchronously."""
3737

38-
supports_triggerer_queue: bool = False
38+
supports_triggerer_queue: bool = True
3939

40-
def __init__(self, callback_path: str, callback_kwargs: dict[str, Any] | None = None):
40+
def __init__(
41+
self,
42+
callback_path: str,
43+
callback_kwargs: dict[str, Any] | None = None,
44+
*,
45+
queue: str | None = None,
46+
):
4147
super().__init__()
4248
self.callback_path = callback_path
4349
self.callback_kwargs = callback_kwargs or {}
50+
# Read by Trigger.from_object() when persisting the row; unused once running.
51+
self.queue = queue
4452

4553
def serialize(self) -> tuple[str, dict[str, Any]]:
4654
return (

airflow-core/tests/unit/models/test_callback.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,15 @@ def test_get_metric_info(self):
117117
"dag_id": TEST_DAG_ID,
118118
}
119119

120+
def test_get_metric_info_includes_queue(self):
121+
"""``queue`` is stored in ``self.data`` for a queued AsyncCallback, so it flows into metric tags."""
122+
queued_callback = AsyncCallback(async_callback, kwargs=TEST_CALLBACK_KWARGS, queue="custom-queue")
123+
callback = TriggererCallback(queued_callback, prefix="deadline_alerts", dag_id=TEST_DAG_ID)
124+
125+
metric_info = callback.get_metric_info(CallbackState.SUCCESS, "0")
126+
127+
assert metric_info["tags"]["queue"] == "custom-queue"
128+
120129
def test_get_metric_info_dict_values_are_stringified(self):
121130
"""
122131
Regression for ``TypeError: unhashable type: 'dict'`` raised by OpenTelemetry's
@@ -185,8 +194,17 @@ def test_queue(self, session):
185194
assert isinstance(callback.trigger, Trigger)
186195
assert callback.trigger.kwargs["callback_path"] == TEST_ASYNC_CALLBACK.path
187196
assert callback.trigger.kwargs["callback_kwargs"] == TEST_ASYNC_CALLBACK.kwargs
197+
assert callback.trigger.queue is None
188198
assert callback.state == CallbackState.QUEUED
189199

200+
def test_queue_populates_trigger_queue(self, session):
201+
queued_callback = AsyncCallback(async_callback, kwargs=TEST_CALLBACK_KWARGS, queue="custom-queue")
202+
callback = TriggererCallback(queued_callback)
203+
204+
callback.queue(session=session)
205+
206+
assert callback.trigger.queue == "custom-queue"
207+
190208
@staticmethod
191209
def _queue_callback(session, *, has_bundle, has_team):
192210
from airflow.models.dagbundle import DagBundleModel

airflow-core/tests/unit/models/test_trigger.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1107,6 +1107,24 @@ def test_serialize_sensitive_kwargs():
11071107
assert "value2" not in trigger_row.encrypted_kwargs
11081108

11091109

1110+
def test_from_object_reads_queue_from_trigger():
1111+
"""A ``queue`` attribute on the trigger object is carried onto the persisted row."""
1112+
trigger_instance = SensitiveKwargsTrigger(param1="value1", param2="value2")
1113+
trigger_instance.queue = "custom-queue"
1114+
1115+
trigger_row: Trigger = Trigger.from_object(trigger_instance)
1116+
1117+
assert trigger_row.queue == "custom-queue"
1118+
1119+
1120+
def test_from_object_defaults_queue_to_none_when_trigger_has_no_queue_attribute():
1121+
trigger_instance = SensitiveKwargsTrigger(param1="value1", param2="value2")
1122+
1123+
trigger_row: Trigger = Trigger.from_object(trigger_instance)
1124+
1125+
assert trigger_row.queue is None
1126+
1127+
11101128
def test_kwargs_not_encrypted():
11111129
"""
11121130
Tests that we don't decrypt kwargs if they aren't encrypted.

airflow-core/tests/unit/triggers/test_callback.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,24 @@ def test_serialization(self, callback_init_kwargs, expected_serialized_kwargs):
7878
"callback_kwargs": expected_serialized_kwargs,
7979
}
8080

81+
def test_supports_triggerer_queue(self):
82+
assert CallbackTrigger.supports_triggerer_queue is True
83+
84+
def test_queue_attribute_is_not_part_of_serialized_kwargs(self):
85+
"""``queue`` is read directly off the trigger by ``Trigger.from_object``, not via serialize()."""
86+
trigger = CallbackTrigger(
87+
callback_path=TEST_CALLBACK_PATH,
88+
callback_kwargs=None,
89+
queue="custom-queue",
90+
)
91+
92+
assert trigger.queue == "custom-queue"
93+
_, kwargs = trigger.serialize()
94+
assert "queue" not in kwargs
95+
96+
def test_queue_defaults_to_none(self, trigger):
97+
assert trigger.queue is None
98+
8199
@pytest.mark.asyncio
82100
async def test_run_success_with_async_function(self, trigger, mock_import_string):
83101
"""Test trigger handles async functions correctly."""

task-sdk/src/airflow/sdk/definitions/callback.py

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -151,16 +151,31 @@ class AsyncCallback(Callback):
151151
triggerer.
152152
153153
It will be called with Airflow context and specified kwargs when a deadline is missed.
154+
155+
Pass ``queue`` to assign the resulting trigger to a specific triggerer queue (see
156+
:ref:`config:triggerer__queues_enabled` and the ``--queues`` option of ``airflow triggerer``).
154157
"""
155158

156-
def __init__(self, callback_callable: Callable | str, kwargs: dict | None = None):
159+
queue: str | None
160+
161+
def __init__(
162+
self,
163+
callback_callable: Callable | str,
164+
kwargs: dict | None = None,
165+
queue: str | None = None,
166+
):
157167
super().__init__(callback_callable=callback_callable, kwargs=kwargs)
168+
self.queue = queue
158169

159170
@classmethod
160171
def verify_callable(cls, callback: Callable):
161172
if not (inspect.iscoroutinefunction(callback) or hasattr(callback, "__await__")):
162173
raise AttributeError(f"Provided callback {callback} is not awaitable.")
163174

175+
@classmethod
176+
def serialized_fields(cls) -> tuple[str, ...]:
177+
return super().serialized_fields() + ("queue",)
178+
164179

165180
class SyncCallback(Callback):
166181
"""

task-sdk/tests/task_sdk/definitions/test_callback.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -240,12 +240,28 @@ def test_init_error(self):
240240
with pytest.raises(AttributeError, match="is not awaitable."):
241241
AsyncCallback(empty_sync_callback_for_deadline_tests)
242242

243+
@pytest.mark.parametrize(
244+
"queue",
245+
[pytest.param("custom-queue", id="with_queue"), pytest.param(None, id="without_queue")],
246+
)
247+
def test_init_queue(self, queue):
248+
callback = AsyncCallback(TEST_CALLBACK_PATH, kwargs=TEST_CALLBACK_KWARGS, queue=queue)
249+
assert callback.queue == queue
250+
243251
def test_serialize_deserialize(self):
244252
callback = AsyncCallback(TEST_CALLBACK_PATH, kwargs=TEST_CALLBACK_KWARGS)
245253
serialized = serialize(callback)
246254
deserialized = cast("Callback", deserialize(serialized.copy()))
247255
assert callback == deserialized
248256

257+
def test_serialize_deserialize_round_trip_keeps_queue(self):
258+
callback = AsyncCallback(TEST_CALLBACK_PATH, kwargs=TEST_CALLBACK_KWARGS, queue="custom-queue")
259+
260+
deserialized = cast("AsyncCallback", deserialize(serialize(callback)))
261+
262+
assert deserialized == callback
263+
assert deserialized.queue == "custom-queue"
264+
249265

250266
class TestSyncCallback:
251267
@pytest.mark.parametrize(

0 commit comments

Comments
 (0)