This is an automated email from the ASF dual-hosted git repository.
vatsrahul1001 pushed a commit to branch v3-3-test
in repository https://gitbox.apache.org/repos/asf/airflow.git
The following commit(s) were added to refs/heads/v3-3-test by this push:
new 16715d605b8 Fix airflow db clean never purging the callback table
(#70923) (#72899)
16715d605b8 is described below
commit 16715d605b88250bb26f33356b26ffad1346cae6
Author: Steve Ahn <[email protected]>
AuthorDate: Thu Sep 10 22:38:29 2026 -0700
Fix airflow db clean never purging the callback table (#70923) (#72899)
* Fix airflow db clean never purging the callback table
The table was renamed from callback_request in Airflow 3.2.0 and the
cleanup configuration was not updated with it. A configured table that
does not exist is skipped with only a warning, so these rows were never
deleted and the table grew without bound, with no way for an operator to
purge it.
Only callbacks that can no longer run are removed. A callback still
awaiting execution owns its deadline row through an ON DELETE CASCADE
foreign key, so deleting one would silently drop a deadline that has not
fired yet.
The sla_miss entry is dropped as well, since that table no longer exists
in Airflow 3.
* Purge orphaned partitioned_asset_key_log rows and restore the db clean
coverage check
The check that is meant to catch a metadata table being left out of
airflow db clean looked for the models package at a path relative to the
test file. That path stopped resolving when the sources moved under
airflow-core, so the check examined no models at all and its assertions
held trivially for an empty set. It has not been able to report a missing
table since, which is how the callback table went unpurged for several
releases.
Walking the package's own search path instead keeps the check working
wherever the sources live, and asserting that models were found stops it
from passing on an empty set again.
With the check restored, partitioned_asset_key_log is the one table it
reports that genuinely has no way to be purged: it carries no foreign
key, so its rows are left behind when the partition Dag run they describe
is cascade-deleted with its dag_run. Only those orphans are deleted --
rows whose partition Dag run still exists are the evidence the scheduler
evaluates to decide when that pending run fires, so they are kept
regardless of age. The other uncovered tables are recorded in the
exclusion list with the reason each is safe.
* Purge orphaned scheduled callbacks while keeping unfired deadlines in db
clean
Every callback starts in SCHEDULED, a state outside both the active and the
terminal sets, and an unfired deadline's callback stays there until the
deadline
is missed. Deny-listing active states would therefore purge those callbacks
and,
through the ON DELETE CASCADE foreign key, silently drop deadlines that
have not
fired. The finished states stay allow-listed, so an unknown state keeps its
rows.
A SCHEDULED callback is purged only once no deadline references it:
deleting a
Dag run cascades away its deadline at the database level, which leaves the
callback behind forever.
(cherry picked from commit 478745af84d5e8418271ff4d5cba39408ed74c4e)
---
airflow-core/newsfragments/70923.bugfix.rst | 1 +
airflow-core/src/airflow/utils/db_cleanup.py | 49 +++-
airflow-core/tests/unit/utils/test_db_cleanup.py | 316 ++++++++++++++++++++++-
3 files changed, 355 insertions(+), 11 deletions(-)
diff --git a/airflow-core/newsfragments/70923.bugfix.rst
b/airflow-core/newsfragments/70923.bugfix.rst
new file mode 100644
index 00000000000..b1545b7fb77
--- /dev/null
+++ b/airflow-core/newsfragments/70923.bugfix.rst
@@ -0,0 +1 @@
+Fix ``airflow db clean`` never purging the ``callback`` table. The table was
renamed from ``callback_request`` in Airflow 3.2.0 but the cleanup
configuration kept the old name, and a configured table that does not exist is
skipped with only a warning, so the rows were never deleted and the table grew
without bound. Only callbacks that can no longer run are purged: a callback
still awaiting execution owns its ``deadline`` row through an ``ON DELETE
CASCADE`` foreign key, so deleting one w [...]
diff --git a/airflow-core/src/airflow/utils/db_cleanup.py
b/airflow-core/src/airflow/utils/db_cleanup.py
index 8197d56c42d..dfd0ef9804a 100644
--- a/airflow-core/src/airflow/utils/db_cleanup.py
+++ b/airflow-core/src/airflow/utils/db_cleanup.py
@@ -32,7 +32,7 @@ from dataclasses import dataclass
from types import SimpleNamespace
from typing import TYPE_CHECKING, Any
-from sqlalchemy import and_, column, func, inspect, literal, select, table,
text
+from sqlalchemy import and_, column, func, inspect, literal, literal_column,
or_, select, table, text
from sqlalchemy.exc import OperationalError, ProgrammingError
from sqlalchemy.ext.compiler import compiles
from sqlalchemy.orm import aliased
@@ -42,9 +42,11 @@ from airflow._shared.timezones import timezone
from airflow.cli.simple_table import AirflowConsole
from airflow.configuration import conf
from airflow.exceptions import AirflowException
+from airflow.models.callback import TERMINAL_STATES
from airflow.utils.db import reflect_tables
from airflow.utils.helpers import ask_yesno
from airflow.utils.session import NEW_SESSION, provide_session
+from airflow.utils.state import CallbackState
from airflow.utils.types import DagRunType
if TYPE_CHECKING:
@@ -57,6 +59,8 @@ if TYPE_CHECKING:
logger = logging.getLogger(__name__)
ARCHIVE_TABLE_PREFIX = "_airflow_deleted__"
+# Alias _build_query gives the table being cleaned; a correlated extra_filter
refers to it by name.
+_BASE_TABLE_ALIAS = "base"
# Archived tables created by DB migrations
ARCHIVED_TABLES_FROM_DB_MIGRATIONS = [
"_xcom_archive" # Table created by the AF 2 -> 3.0.0 migration when the
XComs had pickled values
@@ -161,9 +165,22 @@ config_list: list[_TableConfig] = [
dependent_tables=["task_instance", "task_state_store", "deadline"],
),
_TableConfig(table_name="asset_event", recency_column_name="timestamp",
dag_id_column_name="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
+ # run fires, so age alone must not delete them.
+ _TableConfig(
+ table_name="partitioned_asset_key_log",
+ recency_column_name="created_at",
+ extra_columns=["asset_partition_dag_run_id"],
+ extra_filters=[
+ column("asset_partition_dag_run_id").not_in(
+
select(column("id")).select_from(table("asset_partition_dag_run"))
+ )
+ ],
+ ),
_TableConfig(table_name="import_error", recency_column_name="timestamp"),
_TableConfig(table_name="log", recency_column_name="dttm",
dag_id_column_name="dag_id"),
- _TableConfig(table_name="sla_miss", recency_column_name="timestamp",
dag_id_column_name="dag_id"),
_TableConfig(
table_name="task_instance",
recency_column_name="start_date",
@@ -181,7 +198,31 @@ config_list: list[_TableConfig] = [
_TableConfig(table_name="task_reschedule",
recency_column_name="start_date", dag_id_column_name="dag_id"),
_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(table_name="callback_request",
recency_column_name="created_at"),
+ _TableConfig(
+ table_name="callback",
+ recency_column_name="created_at",
+ extra_columns=["id", "state"],
+ # Purging a callback cascades to its deadline row, so only finished
callbacks are purged;
+ # a state this code does not know keeps its rows. An unfired
deadline's callback sits in
+ # SCHEDULED, which is neither active nor terminal, until the deadline
is missed; it is
+ # purged only once no deadline references it, as deleting a Dag run
cascades away the
+ # deadline at the database level and leaves the callback behind.
Dag-processor callbacks
+ # carry no state and are deleted as they are dispatched.
+ extra_filters=[
+ or_(
+ column("state").in_(sorted(TERMINAL_STATES)),
+ column("state").is_(None),
+ and_(
+ column("state") == CallbackState.SCHEDULED,
+ ~select(literal(1))
+ .select_from(table("deadline", column("callback_id")))
+ .where(column("callback_id") ==
literal_column(f"{_BASE_TABLE_ALIAS}.id"))
+ .exists(),
+ ),
+ )
+ ],
+ dependent_tables=["deadline"],
+ ),
_TableConfig(table_name="celery_taskmeta",
recency_column_name="date_done"),
_TableConfig(table_name="celery_tasksetmeta",
recency_column_name="date_done"),
_TableConfig(
@@ -385,7 +426,7 @@ def _build_query(
referenced_pk_column: str = "id",
**kwargs,
) -> Select:
- base_table_alias = "base"
+ base_table_alias = _BASE_TABLE_ALIAS
base_table = aliased(orm_model, name=base_table_alias)
query = select(text(f"{base_table_alias}.*")).select_from(base_table)
base_table_recency_col = base_table.c[recency_column.name]
diff --git a/airflow-core/tests/unit/utils/test_db_cleanup.py
b/airflow-core/tests/unit/utils/test_db_cleanup.py
index 4d66a96b1a3..0e61c37f6f0 100644
--- a/airflow-core/tests/unit/utils/test_db_cleanup.py
+++ b/airflow-core/tests/unit/utils/test_db_cleanup.py
@@ -20,13 +20,12 @@ from __future__ import annotations
from contextlib import suppress
from importlib import import_module
from io import StringIO
-from pathlib import Path
from unittest.mock import MagicMock, mock_open, patch
from uuid import uuid4
import pendulum
import pytest
-from sqlalchemy import func, inspect, select, text
+from sqlalchemy import func, insert, inspect, select, text
from sqlalchemy.exc import OperationalError, SQLAlchemyError
from sqlalchemy.ext.declarative import DeclarativeMeta
@@ -47,6 +46,7 @@ from airflow.utils.db_cleanup import (
_cleanup_table,
_confirm_drop_archives,
_dump_table_to_file,
+ _effective_table_names,
_get_archived_table_names,
_TableConfig,
config_dict,
@@ -599,11 +599,12 @@ class TestDBCleanup:
"""
import pkgutil
- proj_root = Path(__file__).parents[2].resolve()
- mods = list(
- f"airflow.models.{name}"
- for _, name, _ in pkgutil.iter_modules([str(proj_root /
"airflow/models")])
- )
+ import airflow.models
+
+ # Walk the package's own __path__ rather than rebuilding it from this
file's
+ # location: a path guessed relative to the test resolves to nothing
once the
+ # sources move, leaving the assertions below with an empty set to
check.
+ mods = [f"airflow.models.{name}" for _, name, _ in
pkgutil.iter_modules(airflow.models.__path__)]
all_models = {}
for mod_name in mods:
@@ -643,12 +644,30 @@ class TestDBCleanup:
"dag_priority_parsing_request", # Records are purged once per DAG
Processing loop, not a
# significant source of data.
"dag_bundle", # leave alone - not appropriate for cleanup
+ "team", # leave alone - team configuration, not run data
+ # leave alone - per-asset key/value state, upserted in place (PK
is asset_id+key),
+ # so it is bounded and current, not accumulating history; removed
with its asset
+ "asset_state_store",
+ # Purged indirectly: each of these hangs off a cleaned table by an
+ # ON DELETE CASCADE foreign key, so the rows go when the parent
does.
+ # cascade from dag_run once the partition run has fired; while it
is still
+ # pending its created_dag_run_id is NULL, so those rows are not
cleaned
+ "asset_partition_dag_run",
+ "asset_watcher", # cascade from trigger
+ "dag_favorite", # cascade from dag
+ "deadline_alert", # cascade from serialized_dag, which cascades
from dag_version
+ "hitl_detail", # cascade from task_instance
+ "hitl_detail_history", # cascade from task_instance_history
+ "task_inlet_asset_reference", # cascade from dag
}
from airflow.utils.db_cleanup import config_dict
print(f"all_models={set(all_models)}")
print(f"excl+conf={exclusion_list.union(config_dict)}")
+ # Without this the two assertions below hold trivially for an empty
set,
+ # which is how a table can go unnoticed by this check for several
releases.
+ assert all_models, "discovered no models, so this check would pass
vacuously"
assert set(all_models) - exclusion_list.union(config_dict) == set()
assert exclusion_list.isdisjoint(config_dict)
@@ -1115,3 +1134,286 @@ class TestConnectionTestRequestCleanup:
assert seeded[state] in survivors, f"{state} row should NOT be
cleaned up"
for state in ("success", "failed"):
assert seeded[state] not in survivors, f"{state} row should be
cleaned up"
+
+
+class TestCallbackCleanupConfig:
+ """The callback table is registered under the name it actually has in the
schema."""
+
+ def test_callback_is_configured_under_its_current_name(self):
+ # The table was renamed from callback_request to callback; a config
entry naming a
+ # table that does not exist is skipped with a warning, so the rows are
never purged.
+ assert "callback" in config_dict
+ assert "callback_request" not in config_dict
+
+ def test_removed_tables_are_not_configured(self):
+ assert "sla_miss" not in config_dict
+
+ def test_cleaning_callback_pulls_in_its_dependent_deadline_rows(self):
+ # deadline.callback_id cascades from callback, so deadline has to be
cleaned
+ # (and archived) first, or those rows would vanish unrecorded.
+ selected, _ = _effective_table_names(table_names=["callback"])
+ assert selected == ["deadline", "callback"]
+
+
[email protected]_test
+class TestCallbackCleanup:
+ """Cleanup must purge finished callbacks without disturbing ones that can
still run."""
+
+ def setup_method(self):
+ from tests_common.test_utils.db import clear_db_callbacks,
clear_db_deadline
+
+ clear_db_deadline()
+ clear_db_callbacks()
+ with create_session() as session:
+ for name in _get_archived_table_names(["callback", "deadline"],
session):
+ session.execute(text(f"DROP TABLE {name}"))
+ session.commit()
+
+ def teardown_method(self):
+ from tests_common.test_utils.db import clear_db_callbacks,
clear_db_deadline
+
+ clear_db_deadline()
+ clear_db_callbacks()
+ with create_session() as session:
+ for name in _get_archived_table_names(["callback", "deadline"],
session):
+ session.execute(text(f"DROP TABLE {name}"))
+ session.commit()
+
+ @staticmethod
+ def _add_callback(session, state, created_at):
+ from airflow.executors.workloads.callback import CallbackFetchMethod
+ from airflow.models.callback import Callback
+
+ callback = Callback(priority_weight=1)
+ callback.fetch_method = CallbackFetchMethod.IMPORT_PATH
+ callback.state = state
+ callback.created_at = created_at
+ session.add(callback)
+ session.flush()
+ return callback.id
+
+ @staticmethod
+ def _count_callbacks(session, callback_id):
+ from airflow.models.callback import Callback
+
+ return
session.scalar(select(func.count()).select_from(Callback).where(Callback.id ==
callback_id))
+
+ @staticmethod
+ def _clean_callbacks(cutoff):
+ with create_session() as session:
+ _cleanup_table(
+ **config_dict["callback"].__dict__,
+ clean_before_timestamp=cutoff,
+ dry_run=False,
+ verbose=False,
+ confirm=False,
+ skip_archive=True,
+ session=session,
+ )
+
+ @pytest.mark.parametrize(
+ ("state", "should_survive"),
+ [
+ ("success", False),
+ ("failed", False),
+ (None, False),
+ ("scheduled", False),
+ ("pending", True),
+ ("queued", True),
+ ("running", True),
+ ],
+ ids=[
+ "success",
+ "failed",
+ "dag-processor-null-state",
+ "scheduled-orphan",
+ "pending",
+ "queued",
+ "running",
+ ],
+ )
+ def test_only_finished_callbacks_are_purged(self, state, should_survive):
+ old = pendulum.now(tz="UTC").subtract(days=30)
+ cutoff = pendulum.now(tz="UTC").subtract(days=1)
+
+ with create_session() as session:
+ callback_id = self._add_callback(session, state, old)
+ session.commit()
+
+ self._clean_callbacks(cutoff)
+
+ with create_session() as session:
+ survived = self._count_callbacks(session, callback_id)
+
+ 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)
+ cutoff = pendulum.now(tz="UTC").subtract(days=1)
+ with dag_maker("test_db_clean_unfired_deadline"):
+ pass
+ dag_run = dag_maker.create_dagrun()
+
+ with create_session() as session:
+ deadline = Deadline(
+ deadline_time=pendulum.now(tz="UTC").add(days=10),
+
callback=AsyncCallback("tests.unit.models.test_deadline.callback_for_deadline"),
+ dagrun_id=dag_run.id,
+ deadline_alert_id=None,
+ )
+ session.add(deadline)
+ session.flush()
+ deadline.callback.created_at = old
+ assert deadline.callback.state == CallbackState.SCHEDULED
+ deadline_id, callback_id = deadline.id, deadline.callback.id
+
+ self._clean_callbacks(cutoff)
+
+ with create_session() as session:
+ assert self._count_callbacks(session, callback_id) == 1
+ assert (
+
session.scalar(select(func.count()).select_from(Deadline).where(Deadline.id ==
deadline_id))
+ == 1
+ )
+
+ def test_recent_finished_callback_is_kept(self):
+ recent = pendulum.now(tz="UTC")
+ cutoff = pendulum.now(tz="UTC").subtract(days=1)
+
+ with create_session() as session:
+ callback_id = self._add_callback(session, "success", recent)
+ session.commit()
+
+ self._clean_callbacks(cutoff)
+
+ with create_session() as session:
+ assert self._count_callbacks(session, callback_id) == 1
+
+ def test_finished_callbacks_are_archived_not_just_deleted(self):
+ """Archiving is the default path, so the purged rows must be
recoverable."""
+ from airflow.models.callback import Callback
+
+ old = pendulum.now(tz="UTC").subtract(days=30)
+ cutoff = pendulum.now(tz="UTC").subtract(days=1)
+
+ with create_session() as session:
+ self._add_callback(session, "success", old)
+ session.commit()
+
+ with create_session() as session:
+ _cleanup_table(
+ **config_dict["callback"].__dict__,
+ clean_before_timestamp=cutoff,
+ dry_run=False,
+ verbose=False,
+ confirm=False,
+ skip_archive=False,
+ session=session,
+ )
+
+ with create_session() as session:
+ archives = _get_archived_table_names(["callback"], session)
+ assert archives, "no archive table was created"
+ archived = sum(
+ session.execute(text(f"SELECT count(*) FROM {name}")).scalar()
for name in archives
+ )
+ assert archived == 1
+ assert session.scalar(select(func.count()).select_from(Callback))
== 0
+
+ def test_deadline_of_a_runnable_callback_is_not_cascade_deleted(self):
+ """deadline.callback_id is ON DELETE CASCADE, so purging a live
callback would drop its deadline."""
+ old = pendulum.now(tz="UTC").subtract(days=30)
+ 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(
+ insert(Deadline.__table__).values(
+ id=uuid4(),
+ deadline_time=future,
+ callback_id=callback_id,
+ created_at=old,
+ last_updated_at=old,
+ missed=False,
+ )
+ )
+ session.commit()
+
+ self._clean_callbacks(cutoff)
+
+ with create_session() as session:
+ assert session.scalar(select(func.count()).select_from(Deadline))
== 1
+
+
[email protected]_test
+class TestPartitionedAssetKeyLogCleanup:
+ """Only orphaned key-log rows may go: live ones drive pending
partition-run evaluation."""
+
+ def setup_method(self):
+ with create_session() as session:
+ session.execute(text("DELETE FROM partitioned_asset_key_log"))
+ session.execute(text("DELETE FROM asset_partition_dag_run"))
+ session.commit()
+
+ teardown_method = setup_method
+
+ @staticmethod
+ def _add_key_log(session, apdr_id, created_at):
+ from airflow.models.asset import PartitionedAssetKeyLog
+
+ row = PartitionedAssetKeyLog(
+ asset_id=1,
+ asset_event_id=1,
+ asset_partition_dag_run_id=apdr_id,
+ source_partition_key="2024-01-01",
+ target_dag_id="cleanup_probe_dag",
+ target_partition_key="2024-01-01",
+ )
+ row.created_at = created_at
+ session.add(row)
+ session.flush()
+ return row.id
+
+ def test_only_orphaned_key_log_rows_are_purged(self):
+ from airflow.models.asset import AssetPartitionDagRun,
PartitionedAssetKeyLog
+
+ old = pendulum.now(tz="UTC").subtract(days=30)
+ cutoff = pendulum.now(tz="UTC").subtract(days=1)
+
+ with create_session() as session:
+ apdr = AssetPartitionDagRun(target_dag_id="cleanup_probe_dag",
partition_key="2024-01-01")
+ session.add(apdr)
+ session.flush()
+ ids = {
+ "old orphan": self._add_key_log(session, apdr_id=apdr.id +
1000, created_at=old),
+ "old but live": self._add_key_log(session, apdr_id=apdr.id,
created_at=old),
+ "recent orphan": self._add_key_log(
+ session, apdr_id=apdr.id + 1000,
created_at=pendulum.now(tz="UTC")
+ ),
+ }
+ session.commit()
+
+ with create_session() as session:
+ _cleanup_table(
+ **config_dict["partitioned_asset_key_log"].__dict__,
+ clean_before_timestamp=cutoff,
+ dry_run=False,
+ verbose=False,
+ confirm=False,
+ skip_archive=True,
+ session=session,
+ )
+
+ with create_session() as session:
+ surviving =
set(session.scalars(select(PartitionedAssetKeyLog.id)).all())
+
+ assert ids["old orphan"] not in surviving
+ assert ids["old but live"] in surviving, "evidence for a pending
partition run must not be deleted"
+ assert ids["recent orphan"] in surviving, "age filter still applies to
orphans"