This is an automated email from the ASF dual-hosted git repository.
aminghadersohi pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/superset.git
The following commit(s) were added to refs/heads/master by this push:
new 21b937b59d7 fix(retention): skip models without purge policies before
scanning (#44874)
21b937b59d7 is described below
commit 21b937b59d7c5146fdac31572c12f44bd5032b3a
Author: Mike Bridge <[email protected]>
AuthorDate: Thu Oct 1 23:09:06 2026 -0600
fix(retention): skip models without purge policies before scanning (#44874)
---
docs/docs/using-superset/recently-archived.mdx | 10 ++
superset/tasks/deletion_retention.py | 55 ++++---
tests/unit_tests/tasks/test_deletion_retention.py | 171 +++++++++++++++++++++-
3 files changed, 217 insertions(+), 19 deletions(-)
diff --git a/docs/docs/using-superset/recently-archived.mdx
b/docs/docs/using-superset/recently-archived.mdx
index becd4c8d543..e2347ac40d7 100644
--- a/docs/docs/using-superset/recently-archived.mdx
+++ b/docs/docs/using-superset/recently-archived.mdx
@@ -115,6 +115,16 @@ The `deletion_retention.purge_soft_deleted` background
task purges eligible
archived objects older than the retention window. The default Celery beat
configuration schedules it daily at 00:00 in the configured Celery timezone.
The task skips purging when `SOFT_DELETE` is off or the window is zero.
+Before scanning a model's rows, it checks for a purge policy. Unsupported
+soft-deletable models are retained without a scan or a per-row purge attempt.
+Both live and dry runs report `unsupported_models`, a map from table name to
+`1` for each skipped model (not a row count), and log one warning per model.
+Each skip increments `deletion_retention.unsupported_models.<table>` once per
+run. Unsupported models are excluded from `would_purge` and do not count as
+`cascade_failures`; supported models continue to use their existing policies.
+An empty `unsupported_models` map means no registered models were skipped for
+this reason. This reporting does not add purge support for host-defined models.
+
The window resolves in this order:
1. A host policy, when one is installed as `SOFT_DELETE_RETENTION_DAYS_FUNC`.
diff --git a/superset/tasks/deletion_retention.py
b/superset/tasks/deletion_retention.py
index c4d95a9f632..07a8cabec66 100644
--- a/superset/tasks/deletion_retention.py
+++ b/superset/tasks/deletion_retention.py
@@ -19,11 +19,11 @@
The deletion-domain analog of ``version_history.prune_old_versions``: where
that ages out version rows while keeping the live entity, this removes
entities that are already soft-deleted. For each
-``SoftDeleteMixin`` model it selects rows whose ``deleted_at`` is older than
-the per-workspace window and runs the shared cascade per entity, in bounded
-id-ordered batches. Convergent, not strictly idempotent: a re-run with the
-same clock and data removes nothing, but rows that have since crossed the
-cutoff are purged on a later run.
+``SoftDeleteMixin`` model with a purge policy it selects rows whose
+``deleted_at`` is older than the per-workspace window and runs the shared
+cascade per entity, in bounded id-ordered batches. Convergent, not strictly
+idempotent: a re-run with the same clock and data removes nothing, but rows
+that have since crossed the cutoff are purged on a later run.
"""
from __future__ import annotations
@@ -46,7 +46,10 @@ from superset.commands.deletion_retention.purge_cascade
import (
entity_uuid,
suppress_purge_association_versions,
)
-from superset.commands.deletion_retention.purge_policy import BlockerReason
+from superset.commands.deletion_retention.purge_policy import (
+ BlockerReason,
+ purge_policy_registry,
+)
from superset.commands.deletion_retention.window import
resolve_retention_window
from superset.extensions import celery_app, feature_flag_manager,
stats_logger_manager
from superset.models.helpers import (
@@ -63,8 +66,7 @@ _BATCH: int = 500
def _soft_delete_models() -> list[type[SoftDeleteMixin]]:
- """The registered ``SoftDeleteMixin`` subclasses (dashboards, charts,
- datasets), in a stable order."""
+ """Return all registered soft-delete models in a stable order."""
return list(SoftDeleteMixin._registered_subclasses) # noqa: SLF001
@@ -120,6 +122,16 @@ def _reconcile_unless_dry_run(dry_run: bool) -> None:
audit.reconcile_pending()
+def _report_model_counts(outcome: str, counts: dict[str, int]) -> None:
+ """Emit the per-model row counts for a purge outcome."""
+ entity_type: str
+ count: int
+ for entity_type, count in counts.items():
+ stats_logger_manager.instance.gauge(
+ f"{_METRIC_PREFIX}.{outcome}.{entity_type}", count
+ )
+
+
def _purge_impl(window_days: int, dry_run: bool) -> dict[str, Any]:
"""Run one purge pass across all soft-delete models."""
if window_days == 0 or window_days < -1:
@@ -141,11 +153,21 @@ def _purge_impl(window_days: int, dry_run: bool) ->
dict[str, Any]:
_reconcile_unless_dry_run(dry_run)
purged: dict[str, int] = {}
would_purge: dict[str, int] = {}
+ unsupported_models: dict[str, int] = {}
failures = 0
blocked = 0
for model in _soft_delete_models():
entity_type = _model_table_name(model)
+ if model not in purge_policy_registry():
+ unsupported_models[entity_type] = 1
+ logger.warning(
+ "deletion_retention: skipping %s: no purge policy", entity_type
+ )
+ stats_logger_manager.instance.incr(
+ f"{_METRIC_PREFIX}.unsupported_models.{entity_type}"
+ )
+ continue
purged_n, would_n, failed_n, blocked_n = _purge_model(model, cutoff,
dry_run)
if would_n:
would_purge[entity_type] = would_n
@@ -155,17 +177,15 @@ def _purge_impl(window_days: int, dry_run: bool) ->
dict[str, Any]:
blocked += blocked_n
if dry_run:
- for entity_type, count in would_purge.items():
- stats_logger_manager.instance.gauge(
- f"{_METRIC_PREFIX}.would_purge.{entity_type}", count
- )
+ _report_model_counts("would_purge", would_purge)
logger.info("deletion_retention: DRY RUN would_purge=%s", would_purge)
- return {"dry_run": 1, "would_purge": would_purge}
+ return {
+ "dry_run": 1,
+ "would_purge": would_purge,
+ "unsupported_models": unsupported_models,
+ }
- for entity_type, count in purged.items():
- stats_logger_manager.instance.gauge(
- f"{_METRIC_PREFIX}.purged.{entity_type}", count
- )
+ _report_model_counts("purged", purged)
if failures:
stats_logger_manager.instance.incr(f"{_METRIC_PREFIX}.cascade_failures")
if blocked:
@@ -176,6 +196,7 @@ def _purge_impl(window_days: int, dry_run: bool) ->
dict[str, Any]:
"purged": purged,
"cascade_failures": failures,
"blocked_by_reference": blocked,
+ "unsupported_models": unsupported_models,
}
logger.info("deletion_retention: %s", stats)
return stats
diff --git a/tests/unit_tests/tasks/test_deletion_retention.py
b/tests/unit_tests/tasks/test_deletion_retention.py
index 3608b1ca2d3..a70d4d62f3f 100644
--- a/tests/unit_tests/tasks/test_deletion_retention.py
+++ b/tests/unit_tests/tasks/test_deletion_retention.py
@@ -23,11 +23,14 @@ is preserved as the disable value, and malformed supplied
values defer purge.
import runpy
from datetime import datetime, timedelta
from pathlib import Path
-from typing import Any
-from unittest.mock import MagicMock, patch
+from typing import Any, ClassVar
+from unittest.mock import ANY, call, MagicMock, patch
+from uuid import UUID, uuid4
import pytest
+import sqlalchemy as sa
from flask.config import Config
+from sqlalchemy.orm import Mapped, mapped_column, registry, Session
@pytest.fixture
@@ -235,6 +238,170 @@ def test_clock_uses_now_not_utcnow() -> None:
purge.assert_called_once_with(Slice, now - timedelta(days=30), False)
[email protected]("dry_run", [False, True])
[email protected]("row_count", [0, 1, 3])
+def test_unsupported_model_is_reported_without_scanning(
+ app_config: Config,
+ monkeypatch: pytest.MonkeyPatch,
+ caplog: pytest.LogCaptureFixture,
+ dry_run: bool,
+ row_count: int,
+) -> None:
+ """Skip unsupported models once per run, regardless of eligible row
count."""
+ # avoid app-init regression: model helpers require the app_config fixture
first.
+ from superset.models.helpers import SoftDeleteMixin
+ from superset.tasks import deletion_retention as task
+
+ monkeypatch.setattr(SoftDeleteMixin, "_registered_subclasses", [])
+ mapper_registry: registry = registry()
+
+ @mapper_registry.mapped
+ class UnsupportedModel(SoftDeleteMixin):
+ """A host-style model with the legacy integer scan key but no purge
policy."""
+
+ __tablename__: str = "unsupported_purge_test"
+ __table__: ClassVar[sa.Table]
+ id: Mapped[int] = mapped_column(primary_key=True)
+ uuid: Mapped[UUID] = mapped_column(sa.Uuid, default=uuid4)
+
+ engine: sa.Engine = sa.create_engine("sqlite://")
+ monkeypatch.setitem(app_config, "SOFT_DELETE_PURGE_DRY_RUN", dry_run)
+ try:
+ mapper_registry.metadata.create_all(engine)
+ session: Session
+ with Session(engine) as session:
+ if row_count:
+ session.execute(
+ sa.insert(UnsupportedModel),
+ [
+ {
+ "id": index + 1,
+ "deleted_at": datetime.now() - timedelta(days=90),
+ }
+ for index in range(row_count)
+ ],
+ )
+ session.commit()
+ scan: MagicMock
+ rollback: MagicMock
+ write_ahead: MagicMock
+ counter: MagicMock
+ gauge: MagicMock
+ with (
+ patch.object(task.db, "session", session),
+ patch.object(session, "execute", wraps=session.execute) as
scan,
+ patch.object(session, "rollback", wraps=session.rollback) as
rollback,
+ patch.object(task.audit, "reconcile_pending"),
+ patch.object(task.audit, "write_ahead") as write_ahead,
+ patch.object(task, "resolve_retention_window",
return_value=30),
+ patch.object(
+ task.feature_flag_manager, "is_feature_enabled",
return_value=True
+ ),
+ patch.object(task.stats_logger_manager.instance, "incr") as
counter,
+ patch.object(task.stats_logger_manager.instance, "gauge") as
gauge,
+ ):
+ result: dict[str, Any] = task.purge_soft_deleted.run()
+ if dry_run:
+ assert result["would_purge"] == {}, result
+ else:
+ assert result["cascade_failures"] == 0, result
+ assert result["purged"] == {}, result
+ assert result["blocked_by_reference"] == 0, result
+ assert result["unsupported_models"] == {"unsupported_purge_test":
1}
+ scan.assert_not_called()
+ rollback.assert_not_called()
+ write_ahead.assert_not_called()
+ gauge.assert_not_called()
+ counter.assert_called_once_with(
+ "deletion_retention.unsupported_models.unsupported_purge_test"
+ )
+ assert (
+ caplog.text.count("skipping unsupported_purge_test: no purge
policy")
+ == 1
+ )
+ assert (
+ session.scalar(
+
sa.select(sa.func.count()).select_from(UnsupportedModel.__table__)
+ )
+ == row_count
+ )
+ finally:
+ mapper_registry.dispose()
+ engine.dispose()
+
+
[email protected]("dry_run", [False, True])
+def test_unsupported_model_does_not_prevent_supported_models_from_purging(
+ app_config: Config,
+ monkeypatch: pytest.MonkeyPatch,
+ caplog: pytest.LogCaptureFixture,
+ dry_run: bool,
+) -> None:
+ """A skipped model must not stop the remaining supported models in the
run."""
+ # avoid app-init regression: model helpers require the app_config fixture
first.
+ from superset.models.helpers import SoftDeleteMixin
+ from superset.tasks import deletion_retention as task
+
+ supported_models: list[type[SoftDeleteMixin]] =
list(task.purge_policy_registry())
+ monkeypatch.setattr(SoftDeleteMixin, "_registered_subclasses",
supported_models[:])
+
+ class UnsupportedModel(SoftDeleteMixin):
+ """An unsupported root that must never reach the row-processing
boundary."""
+
+ __tablename__: str = "unsupported_mixed_purge_test"
+
+ # Put the unsupported model first to detect an early return or break.
+ monkeypatch.setattr(
+ SoftDeleteMixin,
+ "_registered_subclasses",
+ [UnsupportedModel, *supported_models],
+ )
+ monkeypatch.setitem(app_config, "SOFT_DELETE_PURGE_DRY_RUN", dry_run)
+ purge: MagicMock
+ counter: MagicMock
+ gauge: MagicMock
+ with (
+ patch.object(
+ task, "_purge_model", return_value=(0, 1, 0, 0) if dry_run else
(1, 0, 0, 0)
+ ) as purge,
+ patch.object(task.audit, "reconcile_pending"),
+ patch.object(task, "resolve_retention_window", return_value=30),
+ patch.object(
+ task.feature_flag_manager, "is_feature_enabled", return_value=True
+ ),
+ patch.object(task.stats_logger_manager.instance, "incr") as counter,
+ patch.object(task.stats_logger_manager.instance, "gauge") as gauge,
+ ):
+ result: dict[str, Any] = task.purge_soft_deleted.run()
+
+ assert purge.call_count == len(supported_models)
+ model: type[SoftDeleteMixin]
+ for model in supported_models:
+ assert call(model, ANY, dry_run) in purge.call_args_list
+ assert result["would_purge" if dry_run else "purged"] == {
+ "dashboards": 1,
+ "slices": 1,
+ "tables": 1,
+ }
+ outcome: str = "would_purge" if dry_run else "purged"
+ assert gauge.call_count == 3
+ gauge.assert_has_calls(
+ [
+ call(f"deletion_retention.{outcome}.dashboards", 1),
+ call(f"deletion_retention.{outcome}.slices", 1),
+ call(f"deletion_retention.{outcome}.tables", 1),
+ ],
+ any_order=True,
+ )
+ assert result["unsupported_models"] == {"unsupported_mixed_purge_test": 1}
+ counter.assert_called_once_with(
+ "deletion_retention.unsupported_models.unsupported_mixed_purge_test"
+ )
+ assert (
+ caplog.text.count("skipping unsupported_mixed_purge_test: no purge
policy") == 1
+ )
+
+
def test_default_config_purges_for_real_after_the_retention_window() -> None:
"""The shipped defaults make the docs' retention promise true.