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

vatsrahul1001 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 c3cc5573b80 Batch triggerer unused trigger cleanup deletes (#70961)
c3cc5573b80 is described below

commit c3cc5573b80604de970522308bb6cbcbd5cf5ac4
Author: Shivam Rastogi <[email protected]>
AuthorDate: Sat Oct 17 23:24:44 2026 -0700

    Batch triggerer unused trigger cleanup deletes (#70961)
    
    * Batch triggerer unused trigger cleanup deletes
    
    Large trigger tables make synchronous cleanup hold locks until every 
eligible row is deleted, delaying triggerer progress and database writers.
    
    * Add unreferenced to spelling wordlist
    
    * Protect re-referenced triggers during cleanup
    
    * Yield between unused-trigger cleanup batches
    
    A large cleanup backlog must not keep the triggerer supervisor away from 
runner communication, heartbeat, and metrics work.
    
    * Clarify unused-trigger batch behavior
---
 .../src/airflow/config_templates/config.yml        |   8 +
 airflow-core/src/airflow/models/trigger.py         |  63 +++---
 airflow-core/tests/unit/models/test_trigger.py     | 218 ++++++++++++++++++++-
 docs/spelling_wordlist.txt                         |   1 +
 4 files changed, 256 insertions(+), 34 deletions(-)

diff --git a/airflow-core/src/airflow/config_templates/config.yml 
b/airflow-core/src/airflow/config_templates/config.yml
index 873b519576f..586153619ec 100644
--- a/airflow-core/src/airflow/config_templates/config.yml
+++ b/airflow-core/src/airflow/config_templates/config.yml
@@ -3135,6 +3135,14 @@ triggerer:
       type: integer
       example: ~
       default: "50"
+    unreferenced_triggers_cleanup_batch_size:
+      description: |
+        Maximum number of unreferenced triggers deleted in one triggerer loop 
and database
+        transaction. Remaining triggers are handled in later triggerer loops. 
This value must be at least 1.
+      version_added: 3.4.0
+      type: integer
+      example: ~
+      default: "500"
     on_kill_timeout:
       description: |
         Maximum number of seconds the triggerer will wait for 
``BaseTrigger.on_kill()`` to complete
diff --git a/airflow-core/src/airflow/models/trigger.py 
b/airflow-core/src/airflow/models/trigger.py
index 1c0e1461604..c53262cc5d0 100644
--- a/airflow-core/src/airflow/models/trigger.py
+++ b/airflow-core/src/airflow/models/trigger.py
@@ -38,8 +38,8 @@ from airflow.models.taskinstance import TaskInstance
 from airflow.serialization.enums import stringify_encoding_keys
 from airflow.triggers.base import BaseTaskEndEvent
 from airflow.utils.retries import run_with_db_retries
-from airflow.utils.session import NEW_SESSION, provide_session
-from airflow.utils.sqlalchemy import UtcDateTime, get_dialect_name, 
with_row_locks
+from airflow.utils.session import NEW_SESSION, create_session, provide_session
+from airflow.utils.sqlalchemy import UtcDateTime, with_row_locks
 from airflow.utils.state import TaskInstanceState
 
 if TYPE_CHECKING:
@@ -231,42 +231,45 @@ class Trigger(Base):
         return set(session.scalars(query))
 
     @classmethod
-    @provide_session
-    def clean_unused(cls, *, session: Session = NEW_SESSION) -> None:
+    def clean_unused(cls) -> None:
         """
-        Delete all triggers that have no tasks dependent on them and are not 
associated to an asset.
+        Delete up to one batch of triggers that have no dependent tasks, 
assets, or callbacks.
 
         Triggers have a one-to-many relationship to task instances, so we need 
to clean those up first.
-        Afterward we can drop the triggers not referenced by anyone.
+        Afterward we can drop unreferenced triggers; later triggerer loops 
drain any remaining batches.
         """
-        # Update all task instances with trigger IDs that are not DEFERRED to 
remove them
+        batch_size = conf.getint("triggerer", 
"unreferenced_triggers_cleanup_batch_size", fallback=500)
+        if batch_size <= 0:
+            raise ValueError("[triggerer] 
unreferenced_triggers_cleanup_batch_size must be at least 1")
+
         for attempt in run_with_db_retries():
             with attempt:
-                session.execute(
-                    update(TaskInstance)
-                    .where(
-                        TaskInstance.state != TaskInstanceState.DEFERRED, 
TaskInstance.trigger_id.is_not(None)
+                with create_session(scoped=False) as session:
+                    # Update all task instances with trigger IDs that are not 
DEFERRED to remove them
+                    session.execute(
+                        update(TaskInstance)
+                        .where(
+                            TaskInstance.state != TaskInstanceState.DEFERRED,
+                            TaskInstance.trigger_id.is_not(None),
+                        )
+                        .values(trigger_id=None)
                     )
-                    .values(trigger_id=None)
-                )
-
-        # Get all triggers that have no task instances, assets, or callbacks 
depending on them and delete them
-        ids = select(cls.id).where(
-            ~cls.assets.any(),
-            ~cls.callback.has(),
-            ~cls.task_instance.has(),
-        )
+                    cls._delete_unused_batch(batch_size, session=session)
+
+    @classmethod
+    def _delete_unused_batch(cls, batch_size: int, *, session: Session) -> 
None:
+        unreferenced = (~cls.assets.any(), ~cls.callback.has(), 
~cls.task_instance.has())
+        ids = 
select(cls.id).where(*unreferenced).order_by(cls.id).limit(batch_size)
         ids = with_row_locks(ids, session, of=cls, skip_locked=True, 
key_share=False)
-        if get_dialect_name(session) == "mysql":
-            # MySQL doesn't support DELETE with JOIN, so we need to do it in 
two steps
-            ids_list = list(session.scalars(ids).all())
-            session.execute(
-                
delete(Trigger).where(Trigger.id.in_(ids_list)).execution_options(synchronize_session=False)
-            )
-        else:
-            session.execute(
-                
delete(Trigger).where(Trigger.id.in_(ids)).execution_options(synchronize_session=False)
-            )
+        ids_list = list(session.scalars(ids))
+        if not ids_list:
+            return
+
+        session.execute(
+            delete(cls)
+            .where(cls.id.in_(ids_list), *unreferenced)
+            .execution_options(synchronize_session=False)
+        )
 
     @classmethod
     @provide_session
diff --git a/airflow-core/tests/unit/models/test_trigger.py 
b/airflow-core/tests/unit/models/test_trigger.py
index 716c0f53bfb..d245466a162 100644
--- a/airflow-core/tests/unit/models/test_trigger.py
+++ b/airflow-core/tests/unit/models/test_trigger.py
@@ -27,7 +27,8 @@ import pytest
 import pytz
 import sqlalchemy as sa
 from cryptography.fernet import Fernet
-from sqlalchemy import delete, func, select
+from sqlalchemy import delete, event, func, insert, select, update
+from sqlalchemy.exc import OperationalError
 
 from airflow._shared.timezones import timezone
 from airflow.jobs.job import Job
@@ -188,13 +189,222 @@ def test_clean_unused(session, dag_maker):
     callback = 
TriggererCallback(callback_def=AsyncCallback("classpath.callback"))
     callback.trigger = trigger6
     session.add(callback)
-    session.flush()
+    expected_trigger_ids = {trigger1.id, trigger4.id, trigger5.id, trigger6.id}
+    session.commit()
 
     # Run clear operation
-    Trigger.clean_unused(session=session)
+    Trigger.clean_unused()
+    session.expire_all()
     results = session.scalars(select(Trigger)).all()
     assert len(results) == 4
-    assert {result.id for result in results} == {trigger1.id, trigger4.id, 
trigger5.id, trigger6.id}
+    assert {result.id for result in results} == expected_trigger_ids
+
+
[email protected]("reference_type", ["task-instance", "asset-watcher", 
"callback"])
+def test_delete_unused_batch_rechecks_new_references(session, 
create_task_instance, reference_type):
+    trigger = Trigger(classpath="airflow.triggers.testing.SuccessTrigger", 
kwargs={})
+    session.add(trigger)
+    session.commit()
+    trigger_id = trigger.id
+
+    if reference_type == "task-instance":
+        task_instance = create_task_instance(
+            session=session, logical_date=timezone.utcnow(), 
state=State.DEFERRED
+        )
+        session.commit()
+        add_reference = (
+            update(TaskInstance).where(TaskInstance.id == 
task_instance.id).values(trigger_id=trigger_id)
+        )
+        reference_count = (
+            
select(func.count()).select_from(TaskInstance).where(TaskInstance.id == 
task_instance.id)
+        )
+    elif reference_type == "asset-watcher":
+        asset = AssetModel("race-test")
+        session.add(asset)
+        session.commit()
+        add_reference = insert(AssetWatcherModel).values(
+            name="race-test-watcher", asset_id=asset.id, trigger_id=trigger_id
+        )
+        reference_count = (
+            select(func.count())
+            .select_from(AssetWatcherModel)
+            .where(AssetWatcherModel.asset_id == asset.id, 
AssetWatcherModel.trigger_id == trigger_id)
+        )
+    else:
+        callback = 
TriggererCallback(callback_def=AsyncCallback("classpath.callback"))
+        session.add(callback)
+        session.commit()
+        add_reference = update(Callback).where(Callback.id == 
callback.id).values(trigger_id=trigger_id)
+        reference_count = 
select(func.count()).select_from(Callback).where(Callback.id == callback.id)
+
+    reference_added = False
+
+    def add_reference_before_delete(connection, cursor, statement, parameters, 
context, executemany):
+        nonlocal reference_added
+        normalized_statement = " ".join(statement.lower().replace('"', 
"").replace("`", "").split())
+        if not reference_added and "delete from trigger" in 
normalized_statement:
+            reference_added = True
+            connection.execute(add_reference)
+
+    event.listen(session.bind, "before_cursor_execute", 
add_reference_before_delete)
+    try:
+        Trigger._delete_unused_batch(1, session=session)
+        session.commit()
+    finally:
+        event.remove(session.bind, "before_cursor_execute", 
add_reference_before_delete)
+
+    assert reference_added
+    assert 
session.scalar(select(func.count()).select_from(Trigger).where(Trigger.id == 
trigger_id)) == 1
+    assert session.scalar(reference_count) == 1
+
+
+@conf_vars({("triggerer", "unreferenced_triggers_cleanup_batch_size"): "2"})
+def test_clean_unused_deletes_one_batch_per_call(session):
+    session.add_all(
+        [
+            
Trigger(classpath=f"airflow.triggers.testing.SuccessTrigger{index}", kwargs={})
+            for index in range(5)
+        ]
+    )
+    session.commit()
+
+    for expected_remaining in [3, 1, 0]:
+        Trigger.clean_unused()
+        session.expire_all()
+        assert session.scalar(select(func.count()).select_from(Trigger)) == 
expected_remaining
+
+
+@conf_vars({("triggerer", "unreferenced_triggers_cleanup_batch_size"): "2"})
[email protected](
+    ("trigger_count", "expected_delete_rowcounts", "expected_remaining"),
+    [
+        pytest.param(0, [], 0, id="empty"),
+        pytest.param(1, [1], 0, id="below-batch-size"),
+        pytest.param(2, [2], 0, id="exactly-batch-size"),
+        pytest.param(3, [2], 1, id="above-batch-size"),
+        pytest.param(5, [2], 3, id="backlog-larger-than-batch"),
+    ],
+)
+def test_clean_unused_deletes_at_most_one_batch(
+    session, trigger_count, expected_delete_rowcounts, expected_remaining
+):
+    session.add_all(
+        [
+            
Trigger(classpath=f"airflow.triggers.testing.SuccessTrigger{index}", kwargs={})
+            for index in range(trigger_count)
+        ]
+    )
+    session.commit()
+
+    deleted_rows_per_statement = []
+    commit_count = 0
+
+    def capture_delete_rowcount(connection, cursor, statement, parameters, 
context, executemany):
+        normalized_statement = " ".join(statement.lower().replace('"', 
"").replace("`", "").split())
+        if "delete from trigger" in normalized_statement:
+            deleted_rows_per_statement.append(cursor.rowcount)
+
+    def count_commit(committed_session):
+        nonlocal commit_count
+        commit_count += 1
+
+    event.listen(session.bind, "after_cursor_execute", capture_delete_rowcount)
+    event.listen(session.__class__, "after_commit", count_commit)
+    try:
+        Trigger.clean_unused()
+    finally:
+        event.remove(session.bind, "after_cursor_execute", 
capture_delete_rowcount)
+        event.remove(session.__class__, "after_commit", count_commit)
+
+    assert (deleted_rows_per_statement, commit_count) == 
(expected_delete_rowcounts, 1)
+    session.expire_all()
+    assert session.scalar(select(func.count()).select_from(Trigger)) == 
expected_remaining
+
+
[email protected]("batch_size", [0, -1])
+def 
test_clean_unused_rejects_non_positive_batch_size_before_transaction(session, 
batch_size):
+    commit_count = 0
+
+    def count_commit(committed_session):
+        nonlocal commit_count
+        commit_count += 1
+
+    event.listen(session.__class__, "after_commit", count_commit)
+    try:
+        with conf_vars({("triggerer", 
"unreferenced_triggers_cleanup_batch_size"): str(batch_size)}):
+            with pytest.raises(
+                ValueError,
+                match=r"\[triggerer\] unreferenced_triggers_cleanup_batch_size 
must be at least 1",
+            ):
+                Trigger.clean_unused()
+    finally:
+        event.remove(session.__class__, "after_commit", count_commit)
+
+    assert commit_count == 0
+
+
+@conf_vars({("triggerer", "unreferenced_triggers_cleanup_batch_size"): "2"})
+def test_clean_unused_retries_failed_batch_in_fresh_transactions(session):
+    session.add_all(
+        [
+            
Trigger(classpath=f"airflow.triggers.testing.SuccessTrigger{index}", kwargs={})
+            for index in range(5)
+        ]
+    )
+    session.commit()
+
+    Trigger.clean_unused()
+    session.expire_all()
+    assert session.scalar(select(func.count()).select_from(Trigger)) == 3
+
+    delete_attempt_count = 0
+    cleanup_sessions = []
+    commit_count = 0
+    rollback_count = 0
+
+    def fail_delete(connection, cursor, statement, parameters, context, 
executemany):
+        nonlocal delete_attempt_count
+        normalized_statement = " ".join(statement.lower().replace('"', 
"").replace("`", "").split())
+        if "delete from trigger" in normalized_statement:
+            delete_attempt_count += 1
+            raise OperationalError(statement, parameters, 
RuntimeError("injected cleanup failure"))
+
+    def capture_session(cleanup_session, transaction, connection):
+        cleanup_sessions.append(cleanup_session)
+
+    def count_commit(committed_session):
+        nonlocal commit_count
+        commit_count += 1
+
+    def count_rollback(rolled_back_session):
+        nonlocal rollback_count
+        rollback_count += 1
+
+    event.listen(session.bind, "before_cursor_execute", fail_delete)
+    event.listen(session.__class__, "after_begin", capture_session)
+    event.listen(session.__class__, "after_commit", count_commit)
+    event.listen(session.__class__, "after_rollback", count_rollback)
+    try:
+        with pytest.raises(OperationalError, match="injected cleanup failure"):
+            Trigger.clean_unused()
+    finally:
+        event.remove(session.bind, "before_cursor_execute", fail_delete)
+        event.remove(session.__class__, "after_begin", capture_session)
+        event.remove(session.__class__, "after_commit", count_commit)
+        event.remove(session.__class__, "after_rollback", count_rollback)
+
+    assert len({id(cleanup_session) for cleanup_session in cleanup_sessions}) 
== 3
+    assert (delete_attempt_count, commit_count, rollback_count) == (3, 0, 3)
+    session.expire_all()
+    assert session.scalar(select(func.count()).select_from(Trigger)) == 3
+
+    Trigger.clean_unused()
+    session.expire_all()
+    assert session.scalar(select(func.count()).select_from(Trigger)) == 1
+
+    Trigger.clean_unused()
+    session.expire_all()
+    assert session.scalar(select(func.count()).select_from(Trigger)) == 0
 
 
 @patch.object(TriggererCallback, "handle_event")
diff --git a/docs/spelling_wordlist.txt b/docs/spelling_wordlist.txt
index c41bd9013db..660c5b82560 100644
--- a/docs/spelling_wordlist.txt
+++ b/docs/spelling_wordlist.txt
@@ -1903,6 +1903,7 @@ unpause
 unpaused
 unpausing
 unpredicted
+unreferenced
 unsanitized
 unscoped
 untestable

Reply via email to