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

uranusjr 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 6dd0169c8d1 Delete triggers not used by any assets (#54929)
6dd0169c8d1 is described below

commit 6dd0169c8d1b9e78c04e58d4f8385bcf316dffe2
Author: Tzu-ping Chung <[email protected]>
AuthorDate: Thu Aug 28 00:03:42 2025 +0800

    Delete triggers not used by any assets (#54929)
---
 .../src/airflow/jobs/scheduler_job_runner.py       | 20 ++++++-
 .../tests/unit/dag_processing/test_collection.py   |  3 +-
 airflow-core/tests/unit/jobs/test_scheduler_job.py | 69 ++++++++++++++++++----
 airflow-core/tests/unit/jobs/test_triggerer_job.py |  3 +-
 4 files changed, 79 insertions(+), 16 deletions(-)

diff --git a/airflow-core/src/airflow/jobs/scheduler_job_runner.py 
b/airflow-core/src/airflow/jobs/scheduler_job_runner.py
index 300d712e9d9..20a92efeb86 100644
--- a/airflow-core/src/airflow/jobs/scheduler_job_runner.py
+++ b/airflow-core/src/airflow/jobs/scheduler_job_runner.py
@@ -56,6 +56,7 @@ from airflow.models.asset import (
     DagScheduleAssetAliasReference,
     DagScheduleAssetReference,
     TaskOutletAssetReference,
+    asset_trigger_association_table,
 )
 from airflow.models.backfill import Backfill
 from airflow.models.dag import DAG, DagModel
@@ -65,7 +66,7 @@ from airflow.models.dagrun import DagRun
 from airflow.models.dagwarning import DagWarning, DagWarningType
 from airflow.models.serialized_dag import SerializedDagModel
 from airflow.models.taskinstance import TaskInstance
-from airflow.models.trigger import TRIGGER_FAIL_REPR, TriggerFailureReason
+from airflow.models.trigger import TRIGGER_FAIL_REPR, Trigger, 
TriggerFailureReason
 from airflow.stats import Stats
 from airflow.ti_deps.dependencies_states import EXECUTION_STATES
 from airflow.timetables.simple import AssetTriggeredTimetable
@@ -1233,6 +1234,10 @@ class SchedulerJobRunner(BaseJobRunner, LoggingMixin):
             conf.getfloat("scheduler", "parsing_cleanup_interval"),
             self._update_asset_orphanage,
         )
+        timers.call_regular_interval(
+            conf.getfloat("scheduler", "parsing_cleanup_interval"),
+            self._remove_unreferenced_triggers,
+        )
 
         if any(x.is_local for x in self.job.executors):
             bundle_cleanup_mgr = BundleUsageTrackingManager()
@@ -2381,6 +2386,19 @@ class SchedulerJobRunner(BaseJobRunner, LoggingMixin):
 
         return task_instance_heartbeat_timeout_message_details
 
+    @provide_session
+    def _remove_unreferenced_triggers(self, *, session: Session = NEW_SESSION) 
-> None:
+        """Remove triggers that are no longer used by anything."""
+        session.execute(
+            delete(Trigger)
+            .where(
+                
Trigger.id.not_in(select(asset_trigger_association_table.c.trigger_id)),
+                Trigger.id.not_in(select(Deadline.trigger_id)),
+                Trigger.id.not_in(select(TaskInstance.trigger_id)),
+            )
+            .execution_options(synchronize_session="fetch")
+        )
+
     @provide_session
     def _update_asset_orphanage(self, session: Session = NEW_SESSION) -> None:
         """
diff --git a/airflow-core/tests/unit/dag_processing/test_collection.py 
b/airflow-core/tests/unit/dag_processing/test_collection.py
index b253a72a90e..924f7757b33 100644
--- a/airflow-core/tests/unit/dag_processing/test_collection.py
+++ b/airflow-core/tests/unit/dag_processing/test_collection.py
@@ -41,7 +41,7 @@ from airflow.dag_processing.collection import (
 )
 from airflow.exceptions import SerializationError
 from airflow.listeners.listener import get_listener_manager
-from airflow.models import DagModel, DagRun, Trigger
+from airflow.models import DagModel, DagRun
 from airflow.models.asset import (
     AssetActive,
     AssetModel,
@@ -165,7 +165,6 @@ class TestAssetModelOperation:
 
         asset_model = session.scalars(select(AssetModel)).one()
         assert len(asset_model.triggers) == expected_num_triggers
-        assert session.scalar(select(func.count()).select_from(Trigger)) == 
expected_num_triggers
 
     @pytest.mark.parametrize(
         "schedule, model, columns, expected",
diff --git a/airflow-core/tests/unit/jobs/test_scheduler_job.py 
b/airflow-core/tests/unit/jobs/test_scheduler_job.py
index 7b4d31e843c..312edb66f9d 100644
--- a/airflow-core/tests/unit/jobs/test_scheduler_job.py
+++ b/airflow-core/tests/unit/jobs/test_scheduler_job.py
@@ -43,6 +43,7 @@ from airflow.api_fastapi.auth.tokens import JWTGenerator
 from airflow.assets.manager import AssetManager
 from airflow.callbacks.callback_requests import DagCallbackRequest, 
DagRunContext, TaskCallbackRequest
 from airflow.callbacks.database_callback_sink import DatabaseCallbackSink
+from airflow.dag_processing.collection import AssetModelOperation, 
DagModelOperation
 from airflow.exceptions import AirflowException
 from airflow.executors.base_executor import BaseExecutor
 from airflow.executors.executor_constants import MOCK_EXECUTOR
@@ -50,14 +51,7 @@ from airflow.executors.executor_loader import ExecutorLoader
 from airflow.executors.executor_utils import ExecutorName
 from airflow.jobs.job import Job, run_job
 from airflow.jobs.scheduler_job_runner import SchedulerJobRunner
-from airflow.models import Deadline
-from airflow.models.asset import (
-    AssetActive,
-    AssetAliasModel,
-    AssetDagRunQueue,
-    AssetEvent,
-    AssetModel,
-)
+from airflow.models.asset import AssetActive, AssetAliasModel, 
AssetDagRunQueue, AssetEvent, AssetModel
 from airflow.models.backfill import Backfill, _create_backfill
 from airflow.models.dag import DAG, DagModel
 from airflow.models.dag_version import DagVersion
@@ -65,15 +59,16 @@ from airflow.models.dagbag import DagBag, sync_bag_to_db
 from airflow.models.dagrun import DagRun
 from airflow.models.dagwarning import DagWarning
 from airflow.models.db_callback_request import DbCallbackRequest
-from airflow.models.deadline import DeadlineCallbackState
+from airflow.models.deadline import Deadline, DeadlineCallbackState
 from airflow.models.log import Log
 from airflow.models.pool import Pool
 from airflow.models.serialized_dag import SerializedDagModel
 from airflow.models.taskinstance import TaskInstance
+from airflow.models.trigger import Trigger
 from airflow.providers.standard.operators.bash import BashOperator
 from airflow.providers.standard.operators.empty import EmptyOperator
-from airflow.sdk import task
-from airflow.sdk.definitions.asset import Asset, AssetAlias
+from airflow.providers.standard.triggers.temporal import DateTimeTrigger
+from airflow.sdk import Asset, AssetAlias, AssetWatcher, task
 from airflow.sdk.definitions.deadline import AsyncCallback
 from airflow.serialization.serialized_objects import LazyDeserializedDAG, 
SerializedDAG
 from airflow.timetables.base import DataInterval
@@ -98,6 +93,7 @@ from tests_common.test_utils.db import (
     clear_db_pools,
     clear_db_runs,
     clear_db_serialized_dags,
+    clear_db_triggers,
     set_default_pool_slots,
 )
 from tests_common.test_utils.mock_executor import MockExecutor
@@ -196,6 +192,7 @@ class TestSchedulerJob:
         clear_db_jobs()
         clear_db_assets()
         clear_db_deadline()
+        clear_db_triggers()
 
     @pytest.fixture(autouse=True)
     def per_test(self) -> Generator:
@@ -6468,6 +6465,56 @@ class TestSchedulerJob:
         assert orphaned == [asset1]
         assert [asset.updated_at for asset in orphaned] == 
updated_at_timestamps
 
+    @pytest.mark.parametrize(
+        "paused, stale, expected_classpath",
+        [
+            pytest.param(
+                False,
+                False,
+                "airflow.providers.standard.triggers.temporal.DateTimeTrigger",
+                id="active",
+            ),
+            pytest.param(False, True, None, id="stale"),
+            pytest.param(True, False, None, id="paused"),
+            pytest.param(True, False, None, id="stale-paused"),
+        ],
+    )
+    def test_delete_unreferenced_triggers(self, dag_maker, session, paused, 
stale, expected_classpath):
+        self.job_runner = SchedulerJobRunner(job=Job())
+
+        classpath, kwargs = DateTimeTrigger(timezone.utcnow()).serialize()
+        asset1 = Asset(
+            name="test_asset_1",
+            watchers=[AssetWatcher(name="test", trigger={"classpath": 
classpath, "kwargs": kwargs})],
+        )
+        with dag_maker(dag_id="dag", schedule=[asset1], session=session) as 
dag:
+            EmptyOperator(task_id="task")
+        dags = {"dag": dag}
+
+        def _update_references() -> None:
+            asset_op = AssetModelOperation.collect(dags)
+            orm_assets = asset_op.sync_assets(session=session)
+            session.flush()
+            asset_op.add_dag_asset_references(orm_dags, orm_assets, 
session=session)
+            asset_op.activate_assets_if_possible(orm_assets.values(), 
session=session)
+            asset_op.add_asset_trigger_references(orm_assets, session=session)
+            session.flush()
+
+        # Initial setup.
+        orm_dags = DagModelOperation({"dag": dag}, "testing", 
None).add_dags(session=session)
+        _update_references()
+        assert session.scalars(select(Trigger.classpath)).one() == classpath
+
+        # Simulate dag state change.
+        orm_dags["dag"].is_paused = paused
+        orm_dags["dag"].is_stale = stale
+        _update_references()
+        assert session.scalars(select(Trigger.classpath)).one() == classpath
+
+        # Unreferenced trigger should be removed.
+        self.job_runner._remove_unreferenced_triggers(session=session)
+        assert session.scalars(select(Trigger.classpath)).one_or_none() == 
expected_classpath
+
     def test_misconfigured_dags_doesnt_crash_scheduler(self, session, 
dag_maker, caplog):
         """Test that if dagrun creation throws an exception, the scheduler 
doesn't crash"""
         with dag_maker("testdag1", serialized=True):
diff --git a/airflow-core/tests/unit/jobs/test_triggerer_job.py 
b/airflow-core/tests/unit/jobs/test_triggerer_job.py
index f0946f91ebd..2ffa92b1997 100644
--- a/airflow-core/tests/unit/jobs/test_triggerer_job.py
+++ b/airflow-core/tests/unit/jobs/test_triggerer_job.py
@@ -43,7 +43,6 @@ from airflow.jobs.triggerer_job_runner import (
     messages,
 )
 from airflow.models import DagBag, DagModel, DagRun, TaskInstance, Trigger
-from airflow.models.baseoperator import BaseOperator
 from airflow.models.connection import Connection
 from airflow.models.dag import DAG
 from airflow.models.dag_version import DagVersion
@@ -54,7 +53,7 @@ from airflow.models.xcom import XComModel
 from airflow.providers.standard.operators.empty import EmptyOperator
 from airflow.providers.standard.operators.python import PythonOperator
 from airflow.providers.standard.triggers.temporal import DateTimeTrigger, 
TimeDeltaTrigger
-from airflow.sdk import BaseHook
+from airflow.sdk import BaseHook, BaseOperator
 from airflow.triggers.base import BaseTrigger, TriggerEvent
 from airflow.triggers.testing import FailureTrigger, SuccessTrigger
 from airflow.utils.state import State, TaskInstanceState

Reply via email to