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

kaxil 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 568d6b3cce8 Fix airflow db clean silently skipping three tables when 
scoped to Dags (#73173)
568d6b3cce8 is described below

commit 568d6b3cce81544226ea9979a20dd3abcde5640e
Author: Kaxil Naik <[email protected]>
AuthorDate: Wed Sep 16 18:11:46 2026 +0100

    Fix airflow db clean silently skipping three tables when scoped to Dags 
(#73173)
---
 airflow-core/src/airflow/utils/db_cleanup.py     | 101 ++++++++++++++++-
 airflow-core/tests/unit/utils/test_db_cleanup.py | 136 ++++++++++++++++++++++-
 2 files changed, 226 insertions(+), 11 deletions(-)

diff --git a/airflow-core/src/airflow/utils/db_cleanup.py 
b/airflow-core/src/airflow/utils/db_cleanup.py
index e956fcf2e52..53e1971b8e7 100644
--- a/airflow-core/src/airflow/utils/db_cleanup.py
+++ b/airflow-core/src/airflow/utils/db_cleanup.py
@@ -82,6 +82,28 @@ def _format_table_name(schema: str | None, table: str) -> 
str:
     return table
 
 
[email protected](frozen=True)
+class _IndirectDagScope:
+    """
+    Describe how to scope a table to a Dag when it carries no ``dag_id`` of 
its own.
+
+    ``--dag-ids`` / ``--exclude-dag-ids`` normally filter on a column of the 
table being
+    cleaned. A table that reaches its Dag only through a foreign key needs the 
filter
+    expressed as a subquery against the referenced table instead.
+
+    :param fk_column: the foreign key on the table being cleaned; must be 
listed in
+        ``extra_columns`` so it is present on the constructed table
+    :param referenced_table: the table ``fk_column`` points at
+    :param referenced_pk_column: the primary key of ``referenced_table`` that 
``fk_column`` matches
+    :param referenced_dag_id_column: the Dag id column on ``referenced_table``
+    """
+
+    fk_column: str
+    referenced_table: str
+    referenced_pk_column: str = "id"
+    referenced_dag_id_column: str = "dag_id"
+
+
 @dataclasses.dataclass
 class _TableConfig:
     """
@@ -97,6 +119,9 @@ class _TableConfig:
     :param keep_last_group_by: if keeping the last record, can keep the last 
record for each group
     :param dependent_tables: list of tables which have FK relationship with 
this table
     :param extra_filters: SQLAlchemy expressions ANDed with the recency 
filter; referenced columns must be in ``extra_columns``.
+    :param dag_id_scope: how to apply ``--dag-ids`` / ``--exclude-dag-ids`` to 
a table that has no
+        Dag id column of its own and reaches its Dag through a foreign key. 
Mutually exclusive with
+        ``dag_id_column_name``.
     :param skip_if_referenced: list of ``(referencing_table, fk_column)`` 
pairs whose FK points at this
         table's ``referenced_pk_column``. A row that is still referenced by 
any of these is excluded from
         deletion. This avoids issuing deletes that would violate an ``ON 
DELETE RESTRICT`` foreign key
@@ -109,6 +134,7 @@ class _TableConfig:
     recency_column_name: str
     extra_columns: list[str] | None = None
     dag_id_column_name: str | None = None
+    dag_id_scope: _IndirectDagScope | None = None
     keep_last: bool = False
     keep_last_filters: Any | None = None
     keep_last_group_by: Any | None = None
@@ -145,6 +171,20 @@ class _TableConfig:
                 schema=self.schema_name,
             )
 
+        if self.dag_id_scope is not None:
+            if self.dag_id_column_name is not None:
+                raise ValueError(
+                    f"_TableConfig for table {self.table_name!r} sets both 
dag_id_column_name and "
+                    f"dag_id_scope; a table is scoped to a Dag either by its 
own column or through a "
+                    f"foreign key, not both."
+                )
+            if self.dag_id_scope.fk_column not in self.orm_model.c.keys():
+                raise ValueError(
+                    f"_TableConfig for table {self.table_name!r} sets 
dag_id_scope but its "
+                    f"fk_column {self.dag_id_scope.fk_column!r} is not one of 
its columns; "
+                    f"add {self.dag_id_scope.fk_column!r} to extra_columns."
+                )
+
         # skip_if_referenced filters on referenced_pk_column, which must be a 
column of orm_model
         # (added via extra_columns). Fail fast with a clear message instead of 
a cryptic KeyError
         # raised later when _build_query evaluates 
base_table.c[referenced_pk_column].
@@ -163,7 +203,12 @@ class _TableConfig:
         return {
             "table": self.table_name,
             "recency_column": str(self.recency_column),
-            "dag_id_column": str(self.dag_id_column),
+            "dag_id_column": (
+                f"{self.dag_id_scope.fk_column} -> "
+                
f"{self.dag_id_scope.referenced_table}.{self.dag_id_scope.referenced_dag_id_column}"
+                if self.dag_id_scope is not None
+                else str(self.dag_id_column)
+            ),
             "keep_last": self.keep_last,
             "keep_last_filters": [str(x) for x in self.keep_last_filters] if 
self.keep_last_filters else None,
             "keep_last_group_by": str(self.keep_last_group_by),
@@ -188,7 +233,11 @@ config_list: list[_TableConfig] = [
         keep_last_group_by=["dag_id"],
         dependent_tables=["task_instance", "task_state_store", "deadline"],
     ),
-    _TableConfig(table_name="asset_event", recency_column_name="timestamp", 
dag_id_column_name="dag_id"),
+    # asset_event has never had a dag_id; the producing Dag is source_dag_id, 
and it is NULL for
+    # events that no task produced (an API-created event, or a watcher).
+    _TableConfig(
+        table_name="asset_event", recency_column_name="timestamp", 
dag_id_column_name="source_dag_id"
+    ),
     # Carries no foreign key, so rows are left behind when the partition Dag 
run they describe
     # is cascade-deleted with its dag_run. Only such orphans may be purged: 
rows whose partition
     # Dag run still exists are the evidence the scheduler evaluates to decide 
when that pending
@@ -219,7 +268,14 @@ config_list: list[_TableConfig] = [
         recency_column_name="expires_at",
         dag_id_column_name="dag_id",
     ),
-    _TableConfig(table_name="task_reschedule", 
recency_column_name="start_date", dag_id_column_name="dag_id"),
+    # task_reschedule.dag_id was dropped in 3.0.0; a reschedule now reaches 
its Dag through its
+    # task instance. ti_id is NOT NULL, so no row is unattributed.
+    _TableConfig(
+        table_name="task_reschedule",
+        recency_column_name="start_date",
+        extra_columns=["ti_id"],
+        dag_id_scope=_IndirectDagScope(fk_column="ti_id", 
referenced_table="task_instance"),
+    ),
     _TableConfig(table_name="xcom", recency_column_name="timestamp", 
dag_id_column_name="dag_id"),
     _TableConfig(table_name="_xcom_archive", recency_column_name="timestamp", 
dag_id_column_name="dag_id"),
     _TableConfig(
@@ -268,7 +324,16 @@ config_list: list[_TableConfig] = [
         # and are cleaned. dag_run.created_dag_version_id is ON DELETE SET 
NULL, so it does not block.
         skip_if_referenced=[("task_instance", "dag_version_id")],
     ),
-    _TableConfig(table_name="deadline", recency_column_name="deadline_time", 
dag_id_column_name="dag_id"),
+    # deadline.dag_id was dropped in 3.1.0; a deadline now reaches its Dag 
through its dag run.
+    # The scope has to follow, because this table is cleaned as a dependent of 
dag_run precisely so
+    # its rows are archived before the ON DELETE CASCADE removes them -- 
leaving it unscoped would
+    # purge deadlines for Dags whose runs --dag-ids / --exclude-dag-ids is 
preserving.
+    _TableConfig(
+        table_name="deadline",
+        recency_column_name="deadline_time",
+        extra_columns=["dagrun_id"],
+        dag_id_scope=_IndirectDagScope(fk_column="dagrun_id", 
referenced_table="dag_run"),
+    ),
     _TableConfig(table_name="revoked_token", recency_column_name="exp"),
     _TableConfig(
         table_name="connection_test_request",
@@ -479,6 +544,7 @@ def _build_query(
     clean_before_timestamp: DateTime,
     session: Session,
     dag_id_column=None,
+    dag_id_scope: _IndirectDagScope | None = None,
     dag_ids: list[str] | None = None,
     exclude_dag_ids: list[str] | None = None,
     extra_filters: list[Any] | None = None,
@@ -517,7 +583,30 @@ def _build_query(
         if dag_ids:
             conditions.append(base_table_dag_id_col.in_(dag_ids))
         if exclude_dag_ids:
-            conditions.append(base_table_dag_id_col.not_in(exclude_dag_ids))
+            # A NULL dag id belongs to no Dag, so it is not one of the 
excluded Dags' rows and stays
+            # eligible. NOT IN alone would yield NULL for it and silently 
retain it forever -- which
+            # is every `job` row, since core never sets Job.dag_id.
+            conditions.append(
+                or_(base_table_dag_id_col.is_(None), 
base_table_dag_id_col.not_in(exclude_dag_ids))
+            )
+    elif (dag_ids or exclude_dag_ids) and dag_id_scope is not None:
+        fk_col = base_table.c[dag_id_scope.fk_column]
+        referenced = table(
+            dag_id_scope.referenced_table,
+            column(dag_id_scope.referenced_pk_column),
+            column(dag_id_scope.referenced_dag_id_column),
+        )
+
+        def _rows_for(target_dag_ids: list[str]):
+            return 
select(referenced.c[dag_id_scope.referenced_pk_column]).where(
+                
referenced.c[dag_id_scope.referenced_dag_id_column].in_(target_dag_ids)
+            )
+
+        if dag_ids:
+            conditions.append(fk_col.in_(_rows_for(dag_ids)))
+        if exclude_dag_ids:
+            # NULL-safe for the same reason as the direct-column branch above.
+            conditions.append(or_(fk_col.is_(None), 
fk_col.not_in(_rows_for(exclude_dag_ids))))
 
     if keep_last:
         max_date_col_name = "max_date_per_group"
@@ -549,6 +638,7 @@ def _cleanup_table(
     keep_last_group_by,
     clean_before_timestamp: DateTime,
     dag_id_column=None,
+    dag_id_scope: _IndirectDagScope | None = None,
     dag_ids=None,
     exclude_dag_ids=None,
     dry_run: bool = True,
@@ -568,6 +658,7 @@ def _cleanup_table(
         orm_model=orm_model,
         recency_column=recency_column,
         dag_id_column=dag_id_column,
+        dag_id_scope=dag_id_scope,
         dag_ids=dag_ids,
         exclude_dag_ids=exclude_dag_ids,
         keep_last=keep_last,
diff --git a/airflow-core/tests/unit/utils/test_db_cleanup.py 
b/airflow-core/tests/unit/utils/test_db_cleanup.py
index f67b45d748c..01e3ade8ce3 100644
--- a/airflow-core/tests/unit/utils/test_db_cleanup.py
+++ b/airflow-core/tests/unit/utils/test_db_cleanup.py
@@ -38,9 +38,11 @@ from airflow.exceptions import AirflowException
 from airflow.models import DagModel, DagRun, TaskInstance
 from airflow.models.dag_version import DagVersion
 from airflow.models.dagbundle import DagBundleModel
+from airflow.models.deadline import Deadline
 from airflow.models.serialized_dag import SerializedDagModel
 from airflow.models.task_state_store import TaskStateStoreModel
 from airflow.providers.standard.operators.python import PythonOperator
+from airflow.sdk.definitions.callback import AsyncCallback
 from airflow.serialization.serialized_objects import LazyDeserializedDAG
 from airflow.utils.db_cleanup import (
     ARCHIVE_TABLE_PREFIX,
@@ -52,6 +54,7 @@ from airflow.utils.db_cleanup import (
     _dump_table_to_file,
     _effective_table_names,
     _get_archived_table_names,
+    _IndirectDagScope,
     _TableConfig,
     config_dict,
     drop_archived_tables,
@@ -63,8 +66,10 @@ from airflow.utils.types import DagRunType
 
 from tests_common.test_utils.db import (
     clear_db_assets,
+    clear_db_callbacks,
     clear_db_dag_bundles,
     clear_db_dags,
+    clear_db_deadline,
     clear_db_runs,
     drop_tables_with_prefix,
 )
@@ -447,6 +452,88 @@ class TestDBCleanup:
                 f"Expected {expected_remaining_dag_ids} to remain, but got 
{remaining_dag_ids}"
             )
 
+    @pytest.mark.parametrize(
+        ("dag_ids", "exclude_dag_ids", "expected_remaining"),
+        [
+            pytest.param(["dag1"], None, {"dag2", None}, 
id="include_scopes_through_dag_run"),
+            pytest.param(None, ["dag1"], {"dag1"}, 
id="exclude_keeps_only_that_dags_deadlines"),
+            pytest.param(["dag1", "dag2"], ["dag2"], {"dag2", None}, 
id="include_and_exclude"),
+            pytest.param(None, None, set(), id="unfiltered_purges_everything"),
+        ],
+    )
+    def test_deadline_cleanup_is_scoped_through_its_dag_run(
+        self, dag_ids, exclude_dag_ids, expected_remaining
+    ):
+        """
+        ``deadline`` has carried no ``dag_id`` since 3.1.0, so it is scoped 
via ``dagrun_id``.
+
+        A deadline with no dag run belongs to no Dag: ``--dag-ids`` must not 
claim it, and
+        ``--exclude-dag-ids`` must not shield it.
+        """
+        base_date = pendulum.DateTime(2022, 1, 1, 
tzinfo=pendulum.timezone("UTC"))
+
+        with create_session() as session:
+            bundle_name = "testing"
+            session.add(DagBundleModel(name=bundle_name))
+            session.flush()
+
+            runs_by_dag = {}
+            for dag_id in ["dag1", "dag2"]:
+                dag = DAG(dag_id=dag_id)
+                session.add(DagModel(dag_id=dag_id, bundle_name=bundle_name))
+                
SerializedDagModel.write_dag(LazyDeserializedDAG.from_dag(dag), 
bundle_name=bundle_name)
+                dag_run = DagRun(
+                    dag_id,
+                    run_id=f"{dag_id}_run",
+                    run_type=DagRunType.MANUAL,
+                    start_date=base_date,
+                )
+                session.add(dag_run)
+                session.flush()
+                runs_by_dag[dag_id] = dag_run.id
+
+            for run_id in runs_by_dag.values():
+                session.add(
+                    Deadline(
+                        deadline_time=base_date,
+                        
callback=AsyncCallback("tests.unit.models.test_deadline.callback_for_deadline"),
+                        dagrun_id=run_id,
+                        deadline_alert_id=None,
+                    )
+                )
+            # A deadline attached to no dag run at all.
+            session.add(
+                Deadline(
+                    deadline_time=base_date,
+                    
callback=AsyncCallback("tests.unit.models.test_deadline.callback_for_deadline"),
+                    dagrun_id=None,
+                    deadline_alert_id=None,
+                )
+            )
+            session.commit()
+
+            run_cleanup(
+                clean_before_timestamp=base_date.add(days=10),
+                table_names=["deadline"],
+                dag_ids=dag_ids,
+                exclude_dag_ids=exclude_dag_ids,
+                dry_run=False,
+                confirm=False,
+                session=session,
+            )
+
+            run_id_to_dag = {run_id: dag_id for dag_id, run_id in 
runs_by_dag.items()}
+            remaining = {
+                run_id_to_dag.get(deadline.dagrun_id) for deadline in 
session.scalars(select(Deadline)).all()
+            }
+            assert remaining == expected_remaining
+
+        # The deadline with no dag run has nothing to cascade from, and 
callback rows are only
+        # reachable deadline -> callback, so neither is removed by the dag/run 
clears in
+        # clean_database. Left behind they leak into whatever runs next 
against this database.
+        clear_db_deadline()
+        clear_db_callbacks()
+
     @pytest.mark.parametrize(
         ("skip_archive", "expected_archives"),
         [pytest.param(True, 0, id="skip_archive"), pytest.param(False, 1, 
id="do_archive")],
@@ -562,6 +649,28 @@ class TestDBCleanup:
                 # "id" intentionally omitted from extra_columns
             )
 
+    def test_table_config_rejects_both_dag_id_column_and_scope(self):
+        """A table reaches its Dag one way or the other, so naming both ways 
must fail fast."""
+        with pytest.raises(ValueError, match="both dag_id_column_name and"):
+            _TableConfig(
+                table_name="deadline",
+                recency_column_name="deadline_time",
+                dag_id_column_name="dag_id",
+                dag_id_scope=_IndirectDagScope(fk_column="dagrun_id", 
referenced_table="dag_run"),
+                # present, so this fails on the conflict rather than on a 
missing fk_column
+                extra_columns=["dagrun_id"],
+            )
+
+    def test_table_config_dag_id_scope_requires_fk_column(self):
+        """A dag_id_scope whose fk_column is not selected must fail fast at 
construction."""
+        with pytest.raises(ValueError, match="fk_column"):
+            _TableConfig(
+                table_name="deadline",
+                recency_column_name="deadline_time",
+                dag_id_scope=_IndirectDagScope(fk_column="dagrun_id", 
referenced_table="dag_run"),
+                # "dagrun_id" intentionally omitted from extra_columns
+            )
+
     def test_do_delete_rolls_back_before_drop_on_failure(self):
         session = MagicMock(spec=Session)
         session.get_bind.return_value.dialect.name = "mysql"
@@ -897,13 +1006,32 @@ class TestDBCleanup:
         assert set(all_models) - exclusion_list.union(config_dict) == set()
         assert exclusion_list.isdisjoint(config_dict)
 
-    def test_no_failure_warnings(self):
+    @pytest.mark.parametrize(
+        ("dag_ids", "exclude_dag_ids"),
+        [
+            pytest.param(None, None, id="unfiltered"),
+            pytest.param(["some_dag"], None, id="include"),
+            pytest.param(None, ["some_dag"], id="exclude"),
+            pytest.param(["some_dag"], ["other_dag"], 
id="include_and_exclude"),
+        ],
+    )
+    def test_no_failure_warnings(self, dag_ids, exclude_dag_ids):
         """
         Ensure every table we have configured (and that is present in the db) 
can be cleaned successfully.
         For example, this checks that the recency column is actually a column.
+
+        The Dag-scoped parametrizations matter as much as the unfiltered one: 
the Dag filter is the
+        only thing that dereferences ``dag_id_column_name`` / 
``dag_id_scope``, so a config naming a
+        column a migration has dropped compiles fine without them. Three 
tables drifted that way
+        across 3.0.0 and 3.1.0 before this was covered.
         """
         with patch("airflow.utils.db_cleanup.logger") as mock_logger:
-            run_cleanup(clean_before_timestamp=timezone.utcnow(), dry_run=True)
+            run_cleanup(
+                clean_before_timestamp=timezone.utcnow(),
+                dag_ids=dag_ids,
+                exclude_dag_ids=exclude_dag_ids,
+                dry_run=True,
+            )
             for call in mock_logger.warning.call_args_list:
                 assert "Encountered error when attempting to clean table" not 
in str(call)
 
@@ -1474,8 +1602,6 @@ class TestCallbackCleanup:
         assert bool(survived) is should_survive
 
     def test_unfired_deadline_callback_and_its_deadline_survive(self, 
dag_maker):
-        from airflow.models.deadline import Deadline
-        from airflow.sdk.definitions.callback import AsyncCallback
         from airflow.utils.state import CallbackState
 
         old = pendulum.now(tz="UTC").subtract(days=30)
@@ -1556,8 +1682,6 @@ class TestCallbackCleanup:
         cutoff = pendulum.now(tz="UTC").subtract(days=1)
         future = pendulum.now(tz="UTC").add(days=365)
 
-        from airflow.models.deadline import Deadline
-
         with create_session() as session:
             callback_id = self._add_callback(session, "scheduled", old)
             session.execute(

Reply via email to