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

ferruzzi 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 b68ea7890b9 Fix airflow db clean IntegrityError when a dag_version is 
referenced by a task_instance (#66350)
b68ea7890b9 is described below

commit b68ea7890b918844f68d34d506b4a442ef3042ee
Author: Jakub Matyszewski <[email protected]>
AuthorDate: Tue Sep 22 21:17:30 2026 +0300

    Fix airflow db clean IntegrityError when a dag_version is referenced by a 
task_instance (#66350)
    
    * Fix IntegrityError in airflow db clean when dag_version is referenced by 
task_instance
    
    The ON DELETE RESTRICT FK from task_instance.dag_version_id means that a
    dag_version row cannot be deleted while any task instance still references 
it.
    The skip_if_referenced filter in _build_query correctly excluded such rows 
from
    the archive (SELECT) step, but _do_delete issued the DELETE using only a PK
    join against the archive table — with no re-check of the FK constraint.
    
    In production Airflow (running while db clean executes) a new task_instance 
can
    be created referencing a dag_version between the archive INSERT commit and 
the
    DELETE, causing a psycopg2 ForeignKeyViolation (IntegrityError) and aborting
    the cleanup command.
    
    Re-apply the same NOT EXISTS guard on the DELETE WHERE clause so rows that 
have
    become referenced since the archive was created are silently skipped 
instead of
    failing the FK.
    
    Also add:
    - test_do_delete_skip_if_referenced_guards_against_race: directly exercises 
the
      _do_delete FK guard by inserting a TI after the archive is created.
    - test_dag_id_column_name_matches_schema: regression guard that reflects 
every
      configured table and asserts dag_id_column_name is an actual DB column,
      addressing reviewer feedback requesting protection against config/schema 
drift.
    
    * Remove redundant local import of _do_delete now exported at module level
    
    * Fix airflow db clean failing when a dag filter is used
    
    asset_event, task_reschedule and deadline were configured to filter on a
    dag_id column that none of them has, so passing --dag-ids or
    --exclude-dag-ids made the cleanup fail on exactly those tables while the
    same command without a dag filter succeeded.
    
    * Add newsfragment for db clean IntegrityError mitigation
    
    * Fix skip_if_referenced race test to use _build_query
    
    The regression test called _do_delete with a raw select(archive_table),
    bypassing the NOT EXISTS guard that _build_query embeds in every SELECT.
    That made continue on deleted == 0 loop forever: the unguarded SELECT
    kept counting the archive row even though the DELETE kept refusing it.
    
    Rewrite the test to build the query via _build_query, as _cleanup_table
    does. The TI reference then keeps the SELECT count at zero from the start
    so the loop exits without issuing any DELETE, which is the correct
    invariant: referenced rows are excluded by the SELECT, not just the DELETE.
    
    * Warn when FK guard skips rows and reproduce the race in the test
    
    When skip_if_referenced causes the DELETE to skip rows, log a warning
    so users can tell their dag_version rows were left behind due to live
    task_instance references, rather than silently continuing.
    
    The race regression test was not actually reproducing the race: it
    inserted the TI before building the query, so the NOT EXISTS guard in
    the SELECT returned zero rows immediately and _do_delete never reached
    the DELETE at all.  Rewrite the test using a patched reflect_tables to
    inject the TI in the exact window between the archive CTAS commit and
    the DELETE, which is the real race scenario.  Add a ``raced`` sentinel
    so the test fails fast if the injection point ever moves rather than
    passing without testing anything.  Also add a second DagVersion so
    raced_old is actually eligible for deletion (dag_version is keep_last
    per dag_id, so a lone version is always the survivor).
    
    Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
    
    ---------
    
    Co-authored-by: arose26 <[email protected]>
    Co-authored-by: Claude Sonnet 4.6 <[email protected]>
---
 airflow-core/newsfragments/66350.bugfix.rst      |   1 +
 airflow-core/src/airflow/utils/db_cleanup.py     |  43 +++++++-
 airflow-core/tests/unit/utils/test_db_cleanup.py | 127 ++++++++++++++++++++++-
 3 files changed, 165 insertions(+), 6 deletions(-)

diff --git a/airflow-core/newsfragments/66350.bugfix.rst 
b/airflow-core/newsfragments/66350.bugfix.rst
new file mode 100644
index 00000000000..b03dfd176e7
--- /dev/null
+++ b/airflow-core/newsfragments/66350.bugfix.rst
@@ -0,0 +1 @@
+Reduces the window where ``airflow db clean`` fails with an ``IntegrityError`` 
when a ``dag_version`` row was still referenced by a ``task_instance`` created 
between the archive ``INSERT`` and the ``DELETE``.
diff --git a/airflow-core/src/airflow/utils/db_cleanup.py 
b/airflow-core/src/airflow/utils/db_cleanup.py
index a6213d7de9e..9c56814632b 100644
--- a/airflow-core/src/airflow/utils/db_cleanup.py
+++ b/airflow-core/src/airflow/utils/db_cleanup.py
@@ -30,7 +30,7 @@ import os
 from collections.abc import Generator
 from contextlib import contextmanager, suppress
 from types import SimpleNamespace
-from typing import TYPE_CHECKING, Any
+from typing import TYPE_CHECKING, Any, cast
 
 from sqlalchemy import and_, column, func, inspect, literal, literal_column, 
or_, select, table, text
 from sqlalchemy.exc import OperationalError, ProgrammingError
@@ -52,6 +52,7 @@ from airflow.utils.types import DagRunType
 if TYPE_CHECKING:
     from pendulum import DateTime
     from sqlalchemy import Select
+    from sqlalchemy.engine import CursorResult
     from sqlalchemy.orm import Session
 
     from airflow.models import Base
@@ -383,7 +384,14 @@ def _dump_table_to_file(*, target_table: str, file_path: 
str, export_format: str
 
 
 def _do_delete(
-    *, query: Select, orm_model: Base, skip_archive: bool, session: Session, 
batch_size: int | None
+    *,
+    query: Select,
+    orm_model: Base,
+    skip_archive: bool,
+    session: Session,
+    batch_size: int | None,
+    skip_if_referenced: list[tuple[str, str]] | None = None,
+    referenced_pk_column: str = "id",
 ) -> None:
     import itertools
     import re
@@ -454,10 +462,37 @@ def _do_delete(
                 delete = source_table.delete().where(
                     and_(*[col == target_table.c[col.name] for col in 
source_table.primary_key.columns])
                 )
+            # Re-apply skip_if_referenced on the DELETE to guard against a 
race where a new
+            # referencing row is created after the archive INSERT committed 
but before the DELETE
+            # runs. Without this the DELETE would violate the ON DELETE 
RESTRICT FK and fail.
+            if skip_if_referenced:
+                pk_col = source_table.c[referenced_pk_column]
+                for referencing_table_name, fk_column in skip_if_referenced:
+                    referencing = table(referencing_table_name, 
column(fk_column))
+                    delete = delete.where(
+                        ~select(literal(1))
+                        .select_from(referencing)
+                        .where(referencing.c[fk_column] == pk_col)
+                        .correlate(source_table)
+                        .exists()
+                    )
             logger.debug("delete statement:\n%s", delete.compile())
-            session.execute(delete)
+            deleted = cast("CursorResult", session.execute(delete)).rowcount
             session.commit()
 
+            # A guarded DELETE (skip_if_referenced) may delete fewer rows than 
the SELECT
+            # found. The SELECT includes the same NOT EXISTS guard, so the 
skipped row is
+            # excluded on the next pass too and the loop drains naturally. 
With --batch-size
+            # set, continuing lets subsequent batches clean rows unaffected by 
the race.
+            if deleted == 0:
+                logger.warning(
+                    "Some rows from %s are still referenced by another table 
and were not "
+                    "deleted; they remain in %s and will be retried on the 
next cleanup run.",
+                    source_table_name,
+                    target_table_name if not skip_archive else "the archive 
(which is being dropped)",
+                )
+                continue
+
         except BaseException:
             error_raised = True
             # Roll back the failed transaction so its locks are released before
@@ -678,6 +713,8 @@ def _cleanup_table(
             skip_archive=skip_archive,
             session=session,
             batch_size=batch_size,
+            skip_if_referenced=skip_if_referenced,
+            referenced_pk_column=referenced_pk_column,
         )
 
     session.commit()
diff --git a/airflow-core/tests/unit/utils/test_db_cleanup.py 
b/airflow-core/tests/unit/utils/test_db_cleanup.py
index 01e3ade8ce3..da3e7bae4a2 100644
--- a/airflow-core/tests/unit/utils/test_db_cleanup.py
+++ b/airflow-core/tests/unit/utils/test_db_cleanup.py
@@ -27,7 +27,18 @@ from uuid import uuid4
 
 import pendulum
 import pytest
-from sqlalchemy import Column, Integer, MetaData, Table, func, insert, 
inspect, literal, select, text
+from sqlalchemy import (
+    Column,
+    Integer,
+    MetaData,
+    Table,
+    func,
+    insert,
+    inspect,
+    literal,
+    select,
+    text,
+)
 from sqlalchemy.exc import IntegrityError, OperationalError, SQLAlchemyError
 from sqlalchemy.ext.declarative import DeclarativeMeta
 from sqlalchemy.orm import Session
@@ -638,6 +649,99 @@ class TestDBCleanup:
         assert latest_id in remaining  # kept by keep_last
         assert orphan_id not in remaining  # old and unreferenced -> pruned
 
+    def test_do_delete_skip_if_referenced_guards_against_race(self):
+        """_do_delete must not issue a DELETE that violates an ON DELETE 
RESTRICT FK.
+
+        Reproduces the real race: the dag_version row passes the SELECT filter 
and is
+        archived, and only then does a task_instance referencing it appear.  
The
+        skip_if_referenced guard on the DELETE must skip the row instead of 
failing with
+        IntegrityError, and the loop must still drain because the next SELECT 
pass
+        re-evaluates the same NOT EXISTS guard and excludes it.
+        """
+        from airflow.utils.db import reflect_tables
+
+        base_date = pendulum.DateTime(2020, 1, 1, 
tzinfo=pendulum.timezone("UTC"))
+        bundle_name = f"race-test-{uuid4()}"
+        dag_id = f"race_dag_{uuid4()}"
+
+        with create_session() as session:
+            session.add(DagBundleModel(name=bundle_name))
+            session.flush()
+            session.add(DagModel(dag_id=dag_id, bundle_name=bundle_name))
+            session.flush()
+
+            raced_old = DagVersion(
+                dag_id=dag_id,
+                version_number=1,
+                bundle_name=bundle_name,
+                created_at=base_date,
+                last_updated=base_date,
+            )
+            # dag_version is keep_last per dag_id, so a lone version is always 
the
+            # keep_last survivor and is never eligible for deletion.  A 
second, newer
+            # version takes that role and leaves raced_old as the deletion 
candidate.
+            latest = DagVersion(
+                dag_id=dag_id,
+                version_number=2,
+                bundle_name=bundle_name,
+                created_at=base_date.add(minutes=1),
+                last_updated=base_date.add(minutes=1),
+            )
+            session.add_all([raced_old, latest])
+            session.flush()
+            raced_old_id, latest_id = raced_old.id, latest.id
+
+            # Query built while nothing references raced_old, so the first 
SELECT pass
+            # returns it and _do_delete archives it.
+            cfg = config_dict["dag_version"]
+            query = _build_query(
+                **cfg.__dict__,
+                clean_before_timestamp=base_date.add(days=10),
+                session=session,
+            )
+
+            dag_run = DagRun(dag_id, run_id="race-run", 
run_type=DagRunType.MANUAL, start_date=base_date)
+            ti = create_task_instance(
+                PythonOperator(task_id="dummy-task", python_callable=print),
+                run_id=dag_run.run_id,
+                dag_version_id=raced_old_id,
+            )
+            ti.dag_id = dag_id
+            ti.start_date = base_date
+
+            raced = False
+
+            def reflect_and_race(tables, session, **kwargs):
+                # _do_delete reflects both source and target right after 
committing the
+                # archive CTAS and right before building the DELETE — this is 
the only
+                # seam between the two that fits the race window.  MySQL 
reflects the
+                # target alone earlier in the same pass, so keying on the 
two-table call
+                # covers both branches.  If this call site moves, the test 
stops
+                # reproducing the race and the ``raced`` assertion below will 
catch it.
+                nonlocal raced
+                if not raced and len(tables) == 2:
+                    raced = True
+                    session.add_all([dag_run, ti])
+                    session.commit()
+                return reflect_tables(tables, session, **kwargs)
+
+            with patch("airflow.utils.db_cleanup.reflect_tables", 
side_effect=reflect_and_race):
+                _do_delete(
+                    query=query,
+                    orm_model=cfg.orm_model,
+                    skip_archive=True,
+                    session=session,
+                    batch_size=None,
+                    skip_if_referenced=cfg.skip_if_referenced,
+                    referenced_pk_column=cfg.referenced_pk_column,
+                )
+
+            remaining = 
set(session.scalars(select(DagVersion.id).where(DagVersion.dag_id == 
dag_id)).all())
+
+        assert raced, "the TI was never inserted mid-pass; the race was not 
reproduced"
+        assert raced_old_id in remaining, "dag_version referenced by a 
task_instance must not be deleted"
+        assert latest_id in remaining, "the keep_last survivor must not be 
deleted"
+
     def test_table_config_skip_if_referenced_requires_pk_column(self):
         """A misconfigured skip_if_referenced (pk not in columns) must fail 
fast at construction."""
         with pytest.raises(ValueError, match="referenced_pk_column"):
@@ -747,7 +851,7 @@ class TestDBCleanup:
         session.get_bind.return_value.dialect.name = "mysql"
         session.connection.return_value = object()
         session.scalars.return_value.one.side_effect = [1, 0]
-        session.execute.side_effect = [None, None, None]
+        session.execute.side_effect = [None, None, MagicMock(rowcount=1)]
 
         metadata, source_table, target_table, query = 
_build_do_delete_test_objects()
 
@@ -810,7 +914,7 @@ class TestDBCleanup:
         session.get_bind.return_value.dialect.name = "mysql"
         session.connection.return_value = object()
         session.scalars.return_value.one.side_effect = [1, 0]
-        session.execute.side_effect = [None, None, None]
+        session.execute.side_effect = [None, None, MagicMock(rowcount=1)]
 
         metadata, source_table, target_table, query = 
_build_do_delete_test_objects()
         drop_failure = OperationalError("DROP TABLE", {}, Exception("disk 
full"))
@@ -1006,6 +1110,23 @@ class TestDBCleanup:
         assert set(all_models) - exclusion_list.union(config_dict) == set()
         assert exclusion_list.isdisjoint(config_dict)
 
+    def test_dag_id_column_name_matches_schema(self):
+        """
+        Regression guard: every dag_id_column_name in config_dict must be an 
actual column in its
+        database table, so that --dag-ids filtering never raises 
UndefinedColumn.
+        """
+        with create_session() as session:
+            insp = inspect(session.bind)
+            existing_tables = set(insp.get_table_names())
+            for table_name, cfg in config_dict.items():
+                if cfg.dag_id_column_name is None or table_name not in 
existing_tables:
+                    continue
+                db_columns = {col["name"] for col in 
insp.get_columns(table_name)}
+                assert cfg.dag_id_column_name in db_columns, (
+                    
f"config_dict[{table_name!r}].dag_id_column_name={cfg.dag_id_column_name!r} "
+                    f"is not a column of table {table_name!r} in the database"
+                )
+
     @pytest.mark.parametrize(
         ("dag_ids", "exclude_dag_ids"),
         [

Reply via email to