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

amoghrajesh 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 1812de86d6b Add on_kill() hook to BaseTrigger to handle user actions 
on triggers (#65590)
1812de86d6b is described below

commit 1812de86d6b2969273156725c9f494374ea64a8b
Author: Amogh Desai <[email protected]>
AuthorDate: Wed Apr 22 18:25:30 2026 +0530

    Add on_kill() hook to BaseTrigger to handle user actions on triggers 
(#65590)
    
    Deferred tasks don’t currently have a standard way to cancel external jobs 
when users take actions (fail, clear, mark success), which can leave orphaned 
jobs running.
    
    Add `on_kill()` to BaseTrigger so trigger authors can handle cleanup and 
cancel external work when a task is terminated.
---
 airflow-core/newsfragments/65590.feature.rst       |  1 +
 .../src/airflow/config_templates/config.yml        |  9 +++
 .../src/airflow/jobs/triggerer_job_runner.py       | 33 +++++++--
 airflow-core/src/airflow/triggers/base.py          | 36 ++++++++++
 airflow-core/tests/unit/jobs/test_triggerer_job.py | 82 ++++++++++++++++++++++
 5 files changed, 157 insertions(+), 4 deletions(-)

diff --git a/airflow-core/newsfragments/65590.feature.rst 
b/airflow-core/newsfragments/65590.feature.rst
new file mode 100644
index 00000000000..840a15c6f04
--- /dev/null
+++ b/airflow-core/newsfragments/65590.feature.rst
@@ -0,0 +1 @@
+Add ``on_kill()`` hook to ``BaseTrigger`` so trigger authors can cancel 
external jobs (BigQuery, Databricks, etc.) when a user marks a deferred task as 
failed, clears it, or marks it succeeded. The hook is a no-op by default and is 
only invoked for user-initiated cancellations — triggerer restarts and 
redistributions do not trigger it.
diff --git a/airflow-core/src/airflow/config_templates/config.yml 
b/airflow-core/src/airflow/config_templates/config.yml
index d9ec3fe9a82..8afce430005 100644
--- a/airflow-core/src/airflow/config_templates/config.yml
+++ b/airflow-core/src/airflow/config_templates/config.yml
@@ -2690,6 +2690,15 @@ triggerer:
       type: integer
       example: ~
       default: "50"
+    on_kill_timeout:
+      description: |
+        Maximum number of seconds the triggerer will wait for 
``BaseTrigger.on_kill()`` to complete
+        before giving up and logging a warning. Prevents a slow or hung 
external API call from
+        blocking the triggerer indefinitely when a deferred task is killed by 
a user.
+      version_added: 3.2.0
+      type: integer
+      example: ~
+      default: "30"
     queues_enabled:
       description: |
         When set to True, deferred tasks will register triggers with the task 
queue they originated from,
diff --git a/airflow-core/src/airflow/jobs/triggerer_job_runner.py 
b/airflow-core/src/airflow/jobs/triggerer_job_runner.py
index 74b83a3851a..5d03094032c 100644
--- a/airflow-core/src/airflow/jobs/triggerer_job_runner.py
+++ b/airflow-core/src/airflow/jobs/triggerer_job_runner.py
@@ -105,6 +105,10 @@ if TYPE_CHECKING:
 
 logger = logging.getLogger(__name__)
 tracer = trace.get_tracer(__name__)
+# Private sentinel passed as the cancel message when a trigger is cancelled by 
user action
+_USER_ACTION_CANCEL_MSG = "__airflow_user_action__"
+
+_ON_CANCEL_TIMEOUT: int = conf.getint("triggerer", "on_kill_timeout", 
fallback=30)
 
 
 def _make_trigger_span(
@@ -1137,12 +1141,15 @@ class TriggerRunner:
         Drain the to_cancel queue and ensure all triggers that are not in the 
DB are cancelled.
 
         This allows the cleanup job to delete them.
+        Passes "user-action" as the cancel message so that run_trigger() knows 
to invoke
+        on_kill(). Triggers in this queue are always removed because this is 
the path in which
+        the user performed some action on the task. Trigger redistribution 
goes through a separate
+        path.
         """
         while self.to_cancel:
             trigger_id = self.to_cancel.popleft()
             if trigger_id in self.triggers:
-                # We only delete if it did not exit already
-                self.triggers[trigger_id]["task"].cancel()
+                
self.triggers[trigger_id]["task"].cancel(_USER_ACTION_CANCEL_MSG)
             await asyncio.sleep(0)
 
     async def cleanup_finished_triggers(self) -> list[int]:
@@ -1331,14 +1338,32 @@ class TriggerRunner:
                     self.events.append((trigger_id, event))
                 span.set_status(Status(StatusCode.OK))
             except asyncio.CancelledError as e:
-                # We get cancelled by the scheduler changing the task state. 
But if we do lets give a nice error
-                # message about it
+                # A trigger can be cancelled for two reasons:
+                #   - The user acted on the task (mark failed / clear / mark 
succeeded).
+                #   - The triggerer is shutting down, here cancel_triggers() 
is not
+                #     involved — the shutdown path cancels tasks directly 
without a message.
+                # Only first case should invoke on_kill().
+                #
+                # For timeout, raise immediately without calling on_kill().
                 if timeout := timeout_after:
                     timeout = timeout.replace(tzinfo=timezone.utc) if not 
timeout.tzinfo else timeout
                     if timeout < timezone.utcnow():
                         await self.log.aerror("Trigger cancelled due to 
timeout")
                         span.set_status(Status(StatusCode.ERROR), 
description=str(e))
                         raise
+                if e.args and e.args[0] == _USER_ACTION_CANCEL_MSG:
+                    await self.log.ainfo("Trigger cancelled by user action, 
invoking on_kill", name=name)
+                    try:
+                        await asyncio.wait_for(trigger.on_kill(), 
timeout=_ON_CANCEL_TIMEOUT)
+                    except TimeoutError:
+                        await self.log.awarning("on_kill() timed out", 
timeout=_ON_CANCEL_TIMEOUT, name=name)
+                    except asyncio.CancelledError:
+                        # on_kill() was itself cancelled (e.g. triggerer 
shutting down
+                        # while on_kill() was running). Swallow it so the outer
+                        # CancelledError (variable `e`) can be re-raised below.
+                        pass
+                    except Exception:
+                        await self.log.awarning("on_kill() raised an 
exception", name=name, exc_info=True)
                 span.set_status(Status(StatusCode.OK), description=str(e))
                 raise
             except Exception as e:
diff --git a/airflow-core/src/airflow/triggers/base.py 
b/airflow-core/src/airflow/triggers/base.py
index 205e830c365..b37448edfea 100644
--- a/airflow-core/src/airflow/triggers/base.py
+++ b/airflow-core/src/airflow/triggers/base.py
@@ -192,6 +192,42 @@ class BaseTrigger(abc.ABC, Templater, LoggingMixin):
         and handle it appropriately (in async-compatible way).
         """
 
+    async def on_kill(self) -> None:
+        """
+        Kill the external job managed by this trigger when the task is killed 
by a user.
+
+        Symmetric with ``BaseOperator.on_kill()`` on the worker side: override 
this method
+        to stop external work (e.g. cancel a BigQuery job, terminate a 
Databricks run) when
+        a user explicitly acts on the deferred task via mark-failed, clear, or 
mark-succeeded.
+
+        **Distinction from** ``cleanup()``:
+
+        - ``cleanup()`` runs on every trigger exit — success, timeout, 
shutdown, and user
+          kill. It is meant for releasing local resources held by this trigger 
instance.
+          Putting external job cancellation in ``cleanup()`` would cancel 
in-flight work
+          on every triggerer restart or rolling deploy.
+        - ``on_kill()`` runs only when a user explicitly kills the task. It is 
the right
+          place to cancel external work you do not want to keep running after 
the user performs an action.
+
+        This only fires when a user acts on the task. It does not fire on:
+
+        - Triggerer shutdown or restart — the trigger is redistributed, not 
cancelled.
+        - Triggerer redistribution to another triggerer process.
+        - Trigger timeout — the trigger is killed, not cancelled by user.
+        - Normal trigger completion (the trigger fired an event).
+
+        This method runs in the triggerer's asyncio event loop, so
+        it must be async-safe. Use ``await`` for any I/O; do not block the 
event loop.
+
+        Exceptions raised here are logged as warnings and do not
+        propagate — they will not affect the task state or the triggerer. 
Implement your
+        own retry or error handling inside this method if needed.
+
+        ``on_kill()`` is given a bounded time to complete. Implementations
+        that call slow external APIs should apply their own timeouts rather 
than relying on
+        the framework bound.
+        """
+
     @staticmethod
     def repr(classpath: str, kwargs: dict[str, Any]):
         kwargs_str = ", ".join(f"{k}={v}" for k, v in kwargs.items())
diff --git a/airflow-core/tests/unit/jobs/test_triggerer_job.py 
b/airflow-core/tests/unit/jobs/test_triggerer_job.py
index 503a3f4834c..08c960fd33f 100644
--- a/airflow-core/tests/unit/jobs/test_triggerer_job.py
+++ b/airflow-core/tests/unit/jobs/test_triggerer_job.py
@@ -46,6 +46,7 @@ from airflow.executors import workloads
 from airflow.executors.workloads.task import TaskInstanceDTO
 from airflow.jobs.job import Job
 from airflow.jobs.triggerer_job_runner import (
+    _USER_ACTION_CANCEL_MSG,
     ToTriggerRunner,
     ToTriggerSupervisor,
     TriggerCommsDecoder,
@@ -382,6 +383,87 @@ class TestTriggerRunner:
             )
         assert {"event": "Trigger cancelled due to timeout", "log_level": 
"error"} in cap_structlog
 
+    def test_run_trigger_calls_on_kill_for_user_action(self, session, 
cap_structlog) -> None:
+        """on_kill() is called when CancelledError carries the user-action 
sentinel."""
+        trigger_runner = TriggerRunner()
+        trigger_runner.triggers = {
+            1: {"task": MagicMock(spec=asyncio.Task), "is_watcher": False, 
"name": "mock_name", "events": 0}
+        }
+        mock_trigger = MagicMock(spec=BaseTrigger)
+        mock_trigger.run.side_effect = 
asyncio.CancelledError(_USER_ACTION_CANCEL_MSG)
+        mock_trigger.task_instance = MagicMock()
+        mock_trigger.task_instance.map_index = -1
+        mock_trigger.on_kill = AsyncMock()
+
+        with pytest.raises(asyncio.CancelledError):
+            asyncio.run(trigger_runner.run_trigger(1, mock_trigger))
+
+        mock_trigger.on_kill.assert_awaited_once()
+        assert {
+            "event": "Trigger cancelled by user action, invoking on_kill",
+            "log_level": "info",
+            "name": "mock_name",
+        } in cap_structlog
+
+    def test_run_trigger_skips_on_kill_without_user_action_message(self, 
session) -> None:
+        """on_kill() is not called when CancelledError has no user-action 
sentinel (shutdown/EOF)."""
+        trigger_runner = TriggerRunner()
+        trigger_runner.triggers = {
+            1: {"task": MagicMock(spec=asyncio.Task), "is_watcher": False, 
"name": "mock_name", "events": 0}
+        }
+        mock_trigger = MagicMock(spec=BaseTrigger)
+        mock_trigger.run.side_effect = asyncio.CancelledError()
+        mock_trigger.task_instance = MagicMock()
+        mock_trigger.task_instance.map_index = -1
+        mock_trigger.on_kill = AsyncMock()
+
+        with pytest.raises(asyncio.CancelledError):
+            asyncio.run(trigger_runner.run_trigger(1, mock_trigger))
+
+        mock_trigger.on_kill.assert_not_called()
+
+    def 
test_run_trigger_on_kill_exception_does_not_swallow_cancelled_error(self, 
session) -> None:
+        """CancelledError propagates even if on_kill() raises."""
+        trigger_runner = TriggerRunner()
+        trigger_runner.triggers = {
+            1: {"task": MagicMock(spec=asyncio.Task), "is_watcher": False, 
"name": "mock_name", "events": 0}
+        }
+        mock_trigger = MagicMock(spec=BaseTrigger)
+        mock_trigger.run.side_effect = 
asyncio.CancelledError(_USER_ACTION_CANCEL_MSG)
+        mock_trigger.task_instance = MagicMock()
+        mock_trigger.task_instance.map_index = -1
+        mock_trigger.on_kill = AsyncMock(side_effect=RuntimeError("external 
API down"))
+        mock_trigger.cleanup = AsyncMock()
+
+        with pytest.raises(asyncio.CancelledError):
+            asyncio.run(trigger_runner.run_trigger(1, mock_trigger))
+
+        mock_trigger.on_kill.assert_awaited_once()
+        mock_trigger.cleanup.assert_awaited_once()
+
+    def test_run_trigger_on_kill_timeout_does_not_block_cleanup(self, session) 
-> None:
+        """A hanging on_kill() is interrupted after the timeout and cleanup 
still runs."""
+        trigger_runner = TriggerRunner()
+        trigger_runner.triggers = {
+            1: {"task": MagicMock(spec=asyncio.Task), "is_watcher": False, 
"name": "mock_name", "events": 0}
+        }
+        mock_trigger = MagicMock(spec=BaseTrigger)
+        mock_trigger.run.side_effect = 
asyncio.CancelledError(_USER_ACTION_CANCEL_MSG)
+        mock_trigger.task_instance = MagicMock()
+        mock_trigger.task_instance.map_index = -1
+
+        async def hanging_on_kill():
+            await asyncio.sleep(9999)
+
+        mock_trigger.on_kill = hanging_on_kill
+        mock_trigger.cleanup = AsyncMock()
+
+        with patch("airflow.jobs.triggerer_job_runner._ON_CANCEL_TIMEOUT", 
0.01):
+            with pytest.raises(asyncio.CancelledError):
+                asyncio.run(trigger_runner.run_trigger(1, mock_trigger))
+
+        mock_trigger.cleanup.assert_awaited_once()
+
     @patch("airflow.jobs.triggerer_job_runner.Trigger._decrypt_kwargs")
     @patch(
         
"airflow.jobs.triggerer_job_runner.TriggerRunner.get_trigger_by_classpath",

Reply via email to