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

vatsrahul1001 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 b9ae12ac432 Add asset ID filtering to database cleanup (#74498)
b9ae12ac432 is described below

commit b9ae12ac432a31910a7acb56b27c93ef84e7a5cd
Author: Hussein Awala <[email protected]>
AuthorDate: Sat Oct 24 21:39:30 2026 +0200

    Add asset ID filtering to database cleanup (#74498)
    
    Administrators need to retain event history independently for different 
assets without cleaning every asset in the table. Restricting the new filter to 
an explicit asset_event selection avoids unintentionally cleaning unrelated 
data.
---
 airflow-core/docs/howto/usage-cli.rst              |   9 ++
 airflow-core/src/airflow/cli/cli_config.py         |   7 +
 .../src/airflow/cli/commands/db_command.py         |   1 +
 airflow-core/src/airflow/utils/db_cleanup.py       |  24 ++-
 .../tests/unit/cli/commands/test_db_command.py     |  84 ++++++++++
 airflow-core/tests/unit/utils/test_db_cleanup.py   | 178 +++++++++++++++++++++
 6 files changed, 302 insertions(+), 1 deletion(-)

diff --git a/airflow-core/docs/howto/usage-cli.rst 
b/airflow-core/docs/howto/usage-cli.rst
index a45b30b2ad0..0a8ecf576a5 100644
--- a/airflow-core/docs/howto/usage-cli.rst
+++ b/airflow-core/docs/howto/usage-cli.rst
@@ -259,6 +259,15 @@ You can filter cleanup to specific DAGs using 
``--dag-ids`` (comma-separated lis
   The ``--dry-run`` output shows a ``dag_id_column`` for each table it would 
clean. Where that column is
   ``None``, the DAG filters do not apply to that table.
 
+To clean events for specific assets, pass ``--asset-ids`` with a 
comma-separated list of positive integer
+asset IDs. This requires explicitly selecting only ``--tables asset_event``; 
omitted or additional tables
+are rejected. The timestamp cutoff still applies, and any Dag filters further 
restrict the events by
+``source_dag_id``. Existing cascading deletes, including queued events, still 
apply.
+
+.. code-block:: bash
+
+    airflow db clean --tables asset_event --asset-ids 1,2 
--clean-before-timestamp '2024-01-01T00:00:00+00:00' --dry-run
+
 By default, ``db clean`` will archive purged rows in tables of the form 
``_airflow_deleted__<table>__<timestamp>``. Cascading attempt data may use a 
unique suffix instead of a timestamp. If you don't want the data preserved in 
this way, you may supply argument ``--skip-archive``.
 
 When you encounter an error without using ``--skip-archive``, an archive table 
may remain in the DB. You can use ``db drop-archived`` to remove it. ``db 
export-archived`` and ``db drop-archived`` also accept the pre-upgrade names 
``xcom`` and ``task_instance_history`` to find older archives.
diff --git a/airflow-core/src/airflow/cli/cli_config.py 
b/airflow-core/src/airflow/cli/cli_config.py
index 961a295ddee..6f9be99f3c2 100644
--- a/airflow-core/src/airflow/cli/cli_config.py
+++ b/airflow-core/src/airflow/cli/cli_config.py
@@ -596,6 +596,12 @@ ARG_DB_ERROR_ON_CLEANUP_FAILURE = Arg(
     help="Command will exit with a non-zero exit code if any table cleanup 
failed. By default errors are suppressed and the command exits 0.",
     action="store_true",
 )
+ARG_ASSET_IDS = Arg(
+    ("--asset-ids",),
+    default=None,
+    help="Only clean events for these comma-separated positive integer asset 
IDs; requires --tables asset_event",
+    type=lambda value: [positive_int(allow_zero=False)(item) for item in 
string_list_type(value)],
+)
 ARG_DAG_IDS = Arg(
     ("--dag-ids",),
     default=None,
@@ -1826,6 +1832,7 @@ DB_COMMANDS = (
             ARG_YES,
             ARG_DB_SKIP_ARCHIVE,
             ARG_DB_BATCH_SIZE,
+            ARG_ASSET_IDS,
             ARG_DAG_IDS,
             ARG_EXCLUDE_DAG_IDS,
             ARG_DB_ERROR_ON_CLEANUP_FAILURE,
diff --git a/airflow-core/src/airflow/cli/commands/db_command.py 
b/airflow-core/src/airflow/cli/commands/db_command.py
index f6c4403f751..ee027222df0 100644
--- a/airflow-core/src/airflow/cli/commands/db_command.py
+++ b/airflow-core/src/airflow/cli/commands/db_command.py
@@ -379,6 +379,7 @@ def cleanup_tables(args):
         batch_size=args.batch_size,
         dag_ids=args.dag_ids,
         exclude_dag_ids=args.exclude_dag_ids,
+        asset_ids=args.asset_ids,
         error_on_cleanup_failure=args.error_on_cleanup_failure,
     )
 
diff --git a/airflow-core/src/airflow/utils/db_cleanup.py 
b/airflow-core/src/airflow/utils/db_cleanup.py
index ca81c3a503f..a3cd6e6679e 100644
--- a/airflow-core/src/airflow/utils/db_cleanup.py
+++ b/airflow-core/src/airflow/utils/db_cleanup.py
@@ -876,12 +876,15 @@ def _confirm_delete(
     tables: list[str],
     dag_ids: list[str] | None = None,
     exclude_dag_ids: list[str] | None = None,
+    asset_ids: list[int] | None = None,
 ) -> None:
     for_tables = f" for tables {tables!r}" if tables else ""
     for_dags = f" for the following dags: {dag_ids!r}" if dag_ids else ""
     excluding_dags = f" excluding the following dags: {exclude_dag_ids!r}" if 
exclude_dag_ids else ""
+    for_assets = f" for asset IDs {asset_ids!r}" if asset_ids is not None else 
""
     question = (
-        f"You have requested that we purge all data prior to 
{date}{for_tables}{for_dags}{excluding_dags}.\n"
+        f"You have requested that we purge all data prior to "
+        f"{date}{for_tables}{for_dags}{excluding_dags}{for_assets}.\n"
         f"This is irreversible.  Consider backing up the tables first and / or 
doing a dry run "
         f"with option --dry-run.\n"
         f"Enter 'delete rows' (without quotes) to proceed."
@@ -1027,6 +1030,7 @@ def run_cleanup(
     table_names: list[str] | None = None,
     dag_ids: list[str] | None = None,
     exclude_dag_ids: list[str] | None = None,
+    asset_ids: list[int] | None = None,
     dry_run: bool = False,
     verbose: bool = False,
     confirm: bool = True,
@@ -1051,6 +1055,8 @@ def run_cleanup(
     :param dag_ids: Optional. List of dag ids to perform maintenance on.  If 
list not provided,
         will perform maintenance on all dags.
     :param exclude_dag_ids: Optional. List of dag ids to exclude from 
maintenance.
+    :param asset_ids: Optional. Only clean events for these asset IDs. 
Requires table_names=["asset_event"].
+        An empty list selects no events.
     :param dry_run: If true, print rows meeting deletion criteria
     :param verbose: If true, may provide more detailed output.
     :param confirm: Require user input to confirm before processing deletions.
@@ -1061,12 +1067,27 @@ def run_cleanup(
         if any per-table cleanup encountered an error. By default errors are 
suppressed, a warning
         summary is logged, and the command exits 0 even if some tables were 
not cleaned.
     """
+    if asset_ids is not None and set(table_names or []) != {"asset_event"}:
+        raise SystemExit("--asset-ids requires explicitly selecting only 
--tables asset_event.")
+
     clean_before_timestamp = timezone.coerce_datetime(clean_before_timestamp)
 
     # Get all tables to clean (root + dependents)
     effective_table_names, effective_config_dict = 
_effective_table_names(table_names=table_names)
+    if asset_ids is not None:
+        asset_config = effective_config_dict["asset_event"]
+        effective_config_dict["asset_event"] = dataclasses.replace(
+            asset_config,
+            extra_columns=[*(asset_config.extra_columns or []), "asset_id"],
+            extra_filters=[
+                *(asset_config.extra_filters or []),
+                literal_column(f"{_BASE_TABLE_ALIAS}.asset_id").in_(asset_ids),
+            ],
+        )
     if dry_run:
         print("Performing dry run for db cleanup.")
+        if asset_ids is not None:
+            print(f"Restricting cleanup to asset IDs {asset_ids!r}.")
         print(
             f"Data prior to {clean_before_timestamp} would be purged "
             f"from tables {effective_table_names} with the following config:\n"
@@ -1078,6 +1099,7 @@ def run_cleanup(
             tables=sorted(effective_table_names),
             dag_ids=dag_ids,
             exclude_dag_ids=exclude_dag_ids,
+            asset_ids=asset_ids,
         )
     existing_tables: set[str] = {
         table
diff --git a/airflow-core/tests/unit/cli/commands/test_db_command.py 
b/airflow-core/tests/unit/cli/commands/test_db_command.py
index df09941b73b..19fcf338c6a 100644
--- a/airflow-core/tests/unit/cli/commands/test_db_command.py
+++ b/airflow-core/tests/unit/cli/commands/test_db_command.py
@@ -865,6 +865,7 @@ class TestCLIDBClean:
             table_names=None,
             dag_ids=None,
             exclude_dag_ids=None,
+            asset_ids=None,
             dry_run=False,
             clean_before_timestamp=pendulum.parse(timestamp, tz=timezone),
             verbose=False,
@@ -889,6 +890,7 @@ class TestCLIDBClean:
             table_names=None,
             dag_ids=None,
             exclude_dag_ids=None,
+            asset_ids=None,
             dry_run=False,
             clean_before_timestamp=pendulum.parse(timestamp),
             verbose=False,
@@ -919,6 +921,7 @@ class TestCLIDBClean:
             table_names=None,
             dag_ids=None,
             exclude_dag_ids=None,
+            asset_ids=None,
             dry_run=False,
             clean_before_timestamp=pendulum.parse("2021-01-01 00:00:00Z"),
             verbose=False,
@@ -949,6 +952,7 @@ class TestCLIDBClean:
             table_names=None,
             dag_ids=None,
             exclude_dag_ids=None,
+            asset_ids=None,
             dry_run=False,
             clean_before_timestamp=pendulum.parse("2021-01-01 00:00:00Z"),
             verbose=False,
@@ -979,6 +983,7 @@ class TestCLIDBClean:
             table_names=None,
             dag_ids=None,
             exclude_dag_ids=None,
+            asset_ids=None,
             dry_run=expected,
             clean_before_timestamp=pendulum.parse("2021-01-01 00:00:00Z"),
             verbose=False,
@@ -1011,6 +1016,7 @@ class TestCLIDBClean:
             table_names=expected,
             dag_ids=None,
             exclude_dag_ids=None,
+            asset_ids=None,
             dry_run=False,
             clean_before_timestamp=pendulum.parse("2021-01-01 00:00:00Z"),
             verbose=False,
@@ -1041,6 +1047,7 @@ class TestCLIDBClean:
             table_names=None,
             dag_ids=None,
             exclude_dag_ids=None,
+            asset_ids=None,
             dry_run=False,
             clean_before_timestamp=pendulum.parse("2021-01-01 00:00:00Z"),
             verbose=expected,
@@ -1071,6 +1078,7 @@ class TestCLIDBClean:
             table_names=None,
             dag_ids=None,
             exclude_dag_ids=None,
+            asset_ids=None,
             dry_run=False,
             clean_before_timestamp=pendulum.parse("2021-01-01 00:00:00Z"),
             verbose=False,
@@ -1104,6 +1112,7 @@ class TestCLIDBClean:
             dry_run=False,
             dag_ids=expected,
             exclude_dag_ids=None,
+            asset_ids=None,
             clean_before_timestamp=pendulum.parse("2021-01-01 00:00:00Z"),
             verbose=False,
             confirm=True,
@@ -1112,6 +1121,79 @@ class TestCLIDBClean:
             error_on_cleanup_failure=False,
         )
 
+    @patch("airflow.cli.commands.db_command.run_cleanup", autospec=True)
+    def test_asset_ids(self, run_cleanup_mock):
+        args = self.parser.parse_args(
+            [
+                "db",
+                "clean",
+                "--clean-before-timestamp",
+                "2021-01-01",
+                "--tables",
+                "asset_event",
+                "--asset-ids",
+                " 1, 2 ",
+                "--dag-ids",
+                "producer",
+                "--exclude-dag-ids",
+                "excluded",
+            ]
+        )
+        db_command.cleanup_tables(args)
+        run_cleanup_mock.assert_called_once_with(
+            table_names=["asset_event"],
+            asset_ids=[1, 2],
+            dag_ids=["producer"],
+            exclude_dag_ids=["excluded"],
+            clean_before_timestamp=pendulum.parse("2021-01-01 00:00:00Z"),
+            dry_run=False,
+            verbose=False,
+            confirm=True,
+            skip_archive=False,
+            batch_size=None,
+            error_on_cleanup_failure=False,
+        )
+
+    @pytest.mark.parametrize("asset_ids", ["", " ", ",", "1,", ",1", "1,,2", 
"0", "-1", "1.5", "abc", "1, 0"])
+    @patch("airflow.cli.commands.db_command.run_cleanup", autospec=True)
+    def test_invalid_asset_ids(self, run_cleanup_mock, asset_ids, capsys):
+        with pytest.raises(SystemExit) as exc_info:
+            db_command.cleanup_tables(
+                self.parser.parse_args(
+                    [
+                        "db",
+                        "clean",
+                        "--clean-before-timestamp",
+                        "2021-01-01",
+                        "--tables",
+                        "asset_event",
+                        f"--asset-ids={asset_ids}",
+                    ]
+                )
+            )
+        assert exc_info.value.code == 2
+        assert "argument --asset-ids: invalid positive int value:" in 
capsys.readouterr().err
+        run_cleanup_mock.assert_not_called()
+
+    @pytest.mark.parametrize(
+        "tables",
+        [[], ["--tables", "log"], ["--tables", "asset_event,log"], 
["--tables", "asset_event,invalid"]],
+    )
+    @patch("airflow.utils.db_cleanup.reflect_tables", autospec=True)
+    @patch("airflow.utils.db_cleanup._cleanup_table", autospec=True)
+    @patch("airflow.utils.db_cleanup._confirm_delete", autospec=True)
+    def test_asset_ids_require_only_asset_event(self, confirm_mock, 
cleanup_mock, reflect_mock, tables):
+        args = self.parser.parse_args(
+            ["db", "clean", "--clean-before-timestamp", "2021-01-01", 
"--asset-ids", "1,2", *tables]
+        )
+        with pytest.raises(
+            SystemExit, match="--asset-ids requires explicitly selecting only 
--tables asset_event"
+        ):
+            db_command.cleanup_tables(args)
+        confirm_mock.assert_not_called()
+        cleanup_mock.assert_not_called()
+        reflect_mock.assert_not_called()
+
     @pytest.mark.parametrize(
         ("extra_args", "expected"), [(["--exclude-dag-ids", "dag1, dag2"], 
["dag1", "dag2"]), ([], None)]
     )
@@ -1136,6 +1218,7 @@ class TestCLIDBClean:
             dry_run=False,
             dag_ids=None,
             exclude_dag_ids=expected,
+            asset_ids=None,
             clean_before_timestamp=pendulum.parse("2021-01-01 00:00:00Z"),
             verbose=False,
             confirm=True,
@@ -1166,6 +1249,7 @@ class TestCLIDBClean:
             dry_run=False,
             dag_ids=None,
             exclude_dag_ids=None,
+            asset_ids=None,
             clean_before_timestamp=pendulum.parse("2021-01-01 00:00:00Z"),
             verbose=False,
             confirm=True,
diff --git a/airflow-core/tests/unit/utils/test_db_cleanup.py 
b/airflow-core/tests/unit/utils/test_db_cleanup.py
index 045a1587977..b7126f1acb9 100644
--- a/airflow-core/tests/unit/utils/test_db_cleanup.py
+++ b/airflow-core/tests/unit/utils/test_db_cleanup.py
@@ -50,6 +50,15 @@ from airflow import DAG, settings
 from airflow._shared.timezones import timezone
 from airflow.exceptions import AirflowException
 from airflow.models import DagModel, DagRun, TaskInstance
+from airflow.models.asset import (
+    AssetAliasModel,
+    AssetDagRunQueue,
+    AssetEvent,
+    AssetModel,
+    DagScheduleAssetReference,
+    asset_alias_asset_event_association_table,
+    association_table,
+)
 from airflow.models.dag_version import DagVersion
 from airflow.models.dagbundle import DagBundleModel
 from airflow.models.deadline import Deadline
@@ -1812,6 +1821,175 @@ class TestDBCleanup:
         )
 
 
+class TestAssetEventCleanup:
+    CUTOFF = pendulum.datetime(2024, 1, 2, tz="UTC")
+
+    @pytest.fixture
+    def asset_events(self, session):
+        drop_tables_with_prefix(ARCHIVE_TABLE_PREFIX)
+        assets = [AssetModel(name=f"asset-{i}", uri=f"test://asset-{i}") for i 
in range(3)]
+        session.add_all(assets)
+        session.flush()
+        asset_ids = [asset.id for asset in assets]
+        events = [
+            AssetEvent(asset_id=asset_id, source_dag_id=source, 
timestamp=self.CUTOFF.add(days=offset))
+            for asset_id in asset_ids
+            for source in ("producer", "other", None)
+            for offset in (-1, 0, 1)
+        ]
+        session.add_all(events)
+        session.flush()
+        rows = {event.id: (event.asset_id, event.source_dag_id, 
event.timestamp) for event in events}
+        session.commit()
+        yield asset_ids, rows
+        drop_tables_with_prefix(ARCHIVE_TABLE_PREFIX)
+
+    @pytest.mark.parametrize(
+        ("dry_run", "skip_archive", "batch_size", "dag_ids", 
"exclude_dag_ids", "sources"),
+        [
+            pytest.param(False, False, None, None, None, {"producer", "other", 
None}, id="archive"),
+            pytest.param(False, False, 2, None, None, {"producer", "other", 
None}, id="batched-archive"),
+            pytest.param(False, True, 2, None, None, {"producer", "other", 
None}, id="batched-skip-archive"),
+            pytest.param(True, False, 2, None, None, {"producer", "other", 
None}, id="dry-run"),
+            pytest.param(False, True, 2, ["producer"], None, {"producer"}, 
id="include-dag"),
+            pytest.param(False, True, 2, None, ["other"], {"producer", None}, 
id="exclude-dag"),
+            pytest.param(
+                False, True, 2, ["producer", "other"], ["other"], 
{"producer"}, id="intersect-dag-filters"
+            ),
+        ],
+    )
+    def test_cleanup_selected_assets(
+        self,
+        asset_events,
+        session,
+        dag_maker,
+        capsys,
+        dry_run,
+        skip_archive,
+        batch_size,
+        dag_ids,
+        exclude_dag_ids,
+        sources,
+    ):
+        asset_ids, rows = asset_events
+        selected_assets = asset_ids[:2]
+        with dag_maker("asset_cleanup_consumer", session=session):
+            pass
+        dag_run = dag_maker.create_dagrun()
+        dag_run_id, dag_id = dag_run.id, dag_run.dag_id
+        alias = AssetAliasModel(name="cleanup-alias")
+        session.add(alias)
+        session.flush()
+        alias_id = alias.id
+        session.add_all(DagScheduleAssetReference(asset_id=asset_id, 
dag_id=dag_id) for asset_id in asset_ids)
+        session.add_all(
+            AssetDagRunQueue(asset_event_id=event_id, asset_id=row[0], 
target_dag_id=dag_id)
+            for event_id, row in rows.items()
+        )
+        session.execute(
+            association_table.insert(),
+            [{"dag_run_id": dag_run_id, "event_id": event_id} for event_id in 
rows],
+        )
+        session.execute(
+            asset_alias_asset_event_association_table.insert(),
+            [{"alias_id": alias_id, "event_id": event_id} for event_id in 
rows],
+        )
+        session.commit()
+        matching = {
+            event_id
+            for event_id, (asset_id, source, timestamp) in rows.items()
+            if asset_id in selected_assets and source in sources and timestamp 
< self.CUTOFF
+        }
+
+        run_cleanup(
+            clean_before_timestamp=self.CUTOFF,
+            table_names=["asset_event"],
+            asset_ids=selected_assets,
+            dag_ids=dag_ids,
+            exclude_dag_ids=exclude_dag_ids,
+            dry_run=dry_run,
+            skip_archive=skip_archive,
+            batch_size=batch_size,
+            confirm=False,
+            error_on_cleanup_failure=True,
+            session=session,
+        )
+
+        remaining = set(rows) if dry_run else set(rows) - matching
+        assert set(session.scalars(select(AssetEvent.id))) == remaining
+        assert set(session.scalars(select(AssetDagRunQueue.asset_event_id))) 
== remaining
+        assert set(session.scalars(select(association_table.c.event_id))) == 
remaining
+        assert 
set(session.scalars(select(asset_alias_asset_event_association_table.c.event_id)))
 == remaining
+        assert set(session.scalars(select(AssetModel.id))) == set(asset_ids)
+        assert session.scalar(select(DagModel.dag_id).where(DagModel.dag_id == 
dag_id)) == dag_id
+        assert session.scalar(select(DagRun.id).where(DagRun.id == 
dag_run_id)) == dag_run_id
+        assert session.scalar(select(AssetAliasModel.id)) == alias_id
+        assert 
set(session.scalars(select(DagScheduleAssetReference.asset_id))) == 
set(asset_ids)
+
+        archives = [
+            name
+            for name in _get_archived_table_names(["asset_event"], session)
+            if name.startswith(f"{ARCHIVE_TABLE_PREFIX}asset_event__")
+        ]
+        if dry_run or skip_archive:
+            assert archives == []
+        else:
+            archived_ids = {
+                event_id
+                for name in archives
+                for event_id in 
session.scalars(select(sa.column("id")).select_from(sa.table(name)))
+            }
+            assert archived_ids == matching
+            assert len(archives) == (len(matching) // batch_size if batch_size 
else 1)
+        if dry_run:
+            output = capsys.readouterr().out
+            assert f"Restricting cleanup to asset IDs {selected_assets!r}." in 
output
+            assert f"Found {len(matching)} rows meeting deletion criteria." in 
output
+
+    @pytest.mark.parametrize("empty_selection", [False, True])
+    def test_asset_filter_is_request_local(self, asset_events, session, 
empty_selection):
+        asset_ids, rows = asset_events
+        selected = [] if empty_selection else asset_ids[:1]
+        run_cleanup(
+            clean_before_timestamp=self.CUTOFF,
+            table_names=["asset_event"],
+            asset_ids=selected,
+            skip_archive=True,
+            confirm=False,
+            error_on_cleanup_failure=True,
+            session=session,
+        )
+        deleted = {
+            event_id
+            for event_id, (asset_id, _, timestamp) in rows.items()
+            if asset_id in selected and timestamp < self.CUTOFF
+        }
+        assert set(session.scalars(select(AssetEvent.id))) == set(rows) - 
deleted
+
+        run_cleanup(
+            clean_before_timestamp=self.CUTOFF,
+            table_names=["asset_event"],
+            asset_ids=None,
+            skip_archive=True,
+            confirm=False,
+            error_on_cleanup_failure=True,
+            session=session,
+        )
+        assert set(session.scalars(select(AssetEvent.id))) == {
+            event_id for event_id, (_, _, timestamp) in rows.items() if 
timestamp >= self.CUTOFF
+        }
+
+    @patch("builtins.input", autospec=True, return_value="cancel")
+    def test_confirmation_includes_asset_ids(self, mock_input, capsys):
+        with pytest.raises(SystemExit, match="User did not confirm"):
+            run_cleanup(
+                clean_before_timestamp=self.CUTOFF,
+                table_names=["asset_event"],
+                asset_ids=[1, 2],
+            )
+        assert "for asset IDs [1, 2]" in capsys.readouterr().out
+
+
 def create_tis(base_date, num_tis, run_type=DagRunType.SCHEDULED):
     from tests_common.test_utils.taskinstance import create_task_instance
 

Reply via email to