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

vatsrahul1001 pushed a commit to branch v3-4-test
in repository https://gitbox.apache.org/repos/asf/airflow.git


The following commit(s) were added to refs/heads/v3-4-test by this push:
     new de4c2047883 [v3-4-test] Fix `Trigger.clean_unused` rejecting a 
caller-provided session (#75096) (#75122)
de4c2047883 is described below

commit de4c20478834347fb82fea7a362a92baad6b06be
Author: github-actions[bot] 
<41898282+github-actions[bot]@users.noreply.github.com>
AuthorDate: Tue Oct 20 09:41:44 2026 +0530

    [v3-4-test] Fix `Trigger.clean_unused` rejecting a caller-provided session 
(#75096) (#75122)
    
    Restore the keyword-only session argument that #70961 removed, so callers
    that run the cleanup inside their own transaction keep working. The model
    method now deletes one batch in the caller's transaction without retrying:
    a retry would have to roll back that transaction and silently discard the
    caller's other writes. The triggerer supervisor owns the retry instead and
    runs each attempt in a new transaction.
    (cherry picked from commit e3110a3092de1c68596520e60b66e6bf84936479)
    
    Co-authored-by: Kaxil Naik <[email protected]>
---
 .../src/airflow/jobs/triggerer_job_runner.py       |  7 ++-
 airflow-core/src/airflow/models/trigger.py         | 31 +++++-----
 airflow-core/tests/unit/jobs/test_triggerer_job.py | 69 +++++++++++++++++++++-
 airflow-core/tests/unit/models/test_trigger.py     | 63 ++++++--------------
 4 files changed, 107 insertions(+), 63 deletions(-)

diff --git a/airflow-core/src/airflow/jobs/triggerer_job_runner.py 
b/airflow-core/src/airflow/jobs/triggerer_job_runner.py
index 0bcc790224b..4c6cc56ceea 100644
--- a/airflow-core/src/airflow/jobs/triggerer_job_runner.py
+++ b/airflow-core/src/airflow/jobs/triggerer_job_runner.py
@@ -139,6 +139,7 @@ from airflow.triggers.shared_stream import 
SharedStreamManager
 from airflow.utils.helpers import prune_dict
 from airflow.utils.log.logging_mixin import LoggingMixin
 from airflow.utils.log.task_log_address import prepare_task_log_contexts, 
render_task_log_filename
+from airflow.utils.retries import run_with_db_retries
 from airflow.utils.session import create_session, provide_session
 
 if TYPE_CHECKING:
@@ -818,8 +819,10 @@ class TriggerRunnerSupervisor(WatchedSubprocess):
         Trigger.submit_event(trigger_id=trigger_id, event=event)
 
     def clean_unused(self) -> None:
-        """Remove triggers that are no longer needed."""
-        Trigger.clean_unused()
+        """Remove triggers that are no longer needed, retrying database errors 
in a new transaction."""
+        for attempt in run_with_db_retries():
+            with attempt, create_session() as session:
+                Trigger.clean_unused(session=session)
 
     def handle_failed_triggers(self):
         """
diff --git a/airflow-core/src/airflow/models/trigger.py 
b/airflow-core/src/airflow/models/trigger.py
index 1713f9aebb7..a7193074f6f 100644
--- a/airflow-core/src/airflow/models/trigger.py
+++ b/airflow-core/src/airflow/models/trigger.py
@@ -37,8 +37,7 @@ from airflow.models.base import Base
 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, create_session, provide_session
+from airflow.utils.session import NEW_SESSION, provide_session
 from airflow.utils.sqlalchemy import UtcDateTime, with_row_locks
 from airflow.utils.state import TaskInstanceState
 
@@ -249,30 +248,30 @@ class Trigger(Base):
         return set(session.scalars(query))
 
     @classmethod
-    def clean_unused(cls) -> None:
+    @provide_session
+    def clean_unused(cls, *, session: Session = NEW_SESSION) -> None:
         """
         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 unreferenced triggers; later triggerer loops 
drain any remaining batches.
+
+        Database errors are not retried; to retry, run the call again in a new 
transaction.
         """
         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:
-                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)
-                    )
-                    cls._delete_unused_batch(batch_size, session=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)
+        )
+        cls._delete_unused_batch(batch_size, session=session)
 
     @classmethod
     def _delete_unused_batch(cls, batch_size: int, *, session: Session) -> 
None:
diff --git a/airflow-core/tests/unit/jobs/test_triggerer_job.py 
b/airflow-core/tests/unit/jobs/test_triggerer_job.py
index 345a5859743..bbf01eba0f1 100644
--- a/airflow-core/tests/unit/jobs/test_triggerer_job.py
+++ b/airflow-core/tests/unit/jobs/test_triggerer_job.py
@@ -47,7 +47,9 @@ from opentelemetry.sdk.trace.export import SimpleSpanProcessor
 from opentelemetry.sdk.trace.export.in_memory_span_exporter import 
InMemorySpanExporter
 from opentelemetry.trace.propagation.tracecontext import 
TraceContextTextMapPropagator
 from pydantic import TypeAdapter
-from sqlalchemy import delete, update
+from sqlalchemy import create_engine, delete, event, func, select, text, update
+from sqlalchemy.exc import OperationalError
+from sqlalchemy.pool import NullPool
 from structlog.typing import FilteringBoundLogger
 
 from airflow._shared.timezones import timezone
@@ -685,6 +687,71 @@ def 
test_heartbeat_watchdog_disabled_when_threshold_is_zero(supervisor_builder,
     perform_heartbeat_mock.assert_called_once()
 
 
+def _is_trigger_delete(statement: str) -> bool:
+    return "delete from trigger" in " ".join(statement.lower().replace('"', 
"").replace("`", "").split())
+
+
+def 
test_clean_unused_retries_failed_batches_in_new_transactions(jobless_supervisor,
 session):
+    session.add(Trigger(classpath="airflow.triggers.testing.SuccessTrigger", 
kwargs={}))
+    session.commit()
+
+    delete_attempt_count = 0
+    begin_count = 0
+
+    def fail_first_two_deletes(connection, cursor, statement, parameters, 
context, executemany):
+        nonlocal delete_attempt_count
+        if _is_trigger_delete(statement):
+            delete_attempt_count += 1
+            if delete_attempt_count <= 2:
+                raise OperationalError(statement, parameters, 
RuntimeError("injected cleanup failure"))
+
+    def count_begin(cleanup_session, transaction, connection):
+        nonlocal begin_count
+        begin_count += 1
+
+    event.listen(session.bind, "before_cursor_execute", fail_first_two_deletes)
+    event.listen(session.__class__, "after_begin", count_begin)
+    try:
+        jobless_supervisor.clean_unused()
+    finally:
+        event.remove(session.bind, "before_cursor_execute", 
fail_first_two_deletes)
+        event.remove(session.__class__, "after_begin", count_begin)
+
+    assert (delete_attempt_count, begin_count) == (3, 3)
+    session.expire_all()
+    assert session.scalar(select(func.count()).select_from(Trigger)) == 0
+
+
[email protected]("postgres")
+def 
test_clean_unused_recovers_after_the_database_connection_drops(jobless_supervisor,
 session):
+    session.add(Trigger(classpath="airflow.triggers.testing.SuccessTrigger", 
kwargs={}))
+    session.commit()
+
+    killer_engine = create_engine(session.bind.url, poolclass=NullPool)
+    terminated = []
+
+    def terminate_connection_before_delete(connection, cursor, statement, 
parameters, context, executemany):
+        if not terminated and _is_trigger_delete(statement):
+            backend_pid = 
connection.connection.dbapi_connection.info.backend_pid
+            with killer_engine.connect() as killer:
+                terminated.append(
+                    killer.execute(
+                        text("SELECT pg_terminate_backend(:pid, 5000)"), 
{"pid": backend_pid}
+                    ).scalar()
+                )
+
+    event.listen(session.bind, "before_cursor_execute", 
terminate_connection_before_delete)
+    try:
+        jobless_supervisor.clean_unused()
+    finally:
+        event.remove(session.bind, "before_cursor_execute", 
terminate_connection_before_delete)
+        killer_engine.dispose()
+
+    assert terminated == [True]
+    session.expire_all()
+    assert session.scalar(select(func.count()).select_from(Trigger)) == 0
+
+
 def test_metric_tags_default_uses_job_hostname(supervisor_builder):
     """metric_tags() defaults to {"hostname": job.hostname} when a job is 
present."""
     supervisor = supervisor_builder()
diff --git a/airflow-core/tests/unit/models/test_trigger.py 
b/airflow-core/tests/unit/models/test_trigger.py
index 3e673126d03..54a89a8ce69 100644
--- a/airflow-core/tests/unit/models/test_trigger.py
+++ b/airflow-core/tests/unit/models/test_trigger.py
@@ -343,24 +343,26 @@ def 
test_clean_unused_rejects_non_positive_batch_size_before_transaction(session
     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)
-        ]
-    )
+def test_clean_unused_runs_in_the_caller_transaction(session):
+    session.add(Trigger(classpath="airflow.triggers.testing.SuccessTrigger", 
kwargs={}))
     session.commit()
 
-    Trigger.clean_unused()
-    session.expire_all()
-    assert session.scalar(select(func.count()).select_from(Trigger)) == 3
+    Trigger.clean_unused(session=session)
+    assert session.scalar(select(func.count()).select_from(Trigger)) == 0
+    session.rollback()
+
+    assert session.scalar(select(func.count()).select_from(Trigger)) == 1
+
+
+def 
test_clean_unused_raises_database_errors_to_the_caller_without_retrying(session):
+    session.add(Trigger(classpath="airflow.triggers.testing.SuccessTrigger", 
kwargs={}))
+    session.commit()
+    # The caller's own pending write must survive the cleanup failing.
+    job = Job()
+    session.add(job)
+    session.flush()
 
     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
@@ -369,42 +371,15 @@ def 
test_clean_unused_retries_failed_batch_in_fresh_transactions(session):
             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()
+            Trigger.clean_unused(session=session)
     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
+    assert delete_attempt_count == 1
+    assert job in session
 
 
 @patch.object(TriggererCallback, "handle_event")

Reply via email to