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 38ad1150ecf feat(retention): cap soft-delete purge and version-history 
prune per run (#45029)
38ad1150ecf is described below

commit 38ad1150ecf03dab064ccbd4f5601b4de84102d7
Author: Mike Bridge <[email protected]>
AuthorDate: Sat Oct 10 16:26:50 2026 -0600

    feat(retention): cap soft-delete purge and version-history prune per run 
(#45029)
    
    Co-authored-by: Claude Opus 5.5 <[email protected]>
---
 UPDATING.md                                        |   4 +
 docs/docs/using-superset/recently-archived.mdx     |  37 +
 docs/docs/using-superset/version-history.mdx       |  33 +-
 superset/config.py                                 |   8 +
 superset/tasks/deletion_retention.py               | 397 +++++++---
 superset/tasks/retention_cap.py                    |  32 +
 superset/tasks/version_history_retention.py        | 282 ++++++--
 .../versioning/retention_prune_tests.py            |  65 ++
 .../deletion_retention/test_purge_chart_detach.py  |  18 +-
 tests/unit_tests/tasks/test_deletion_retention.py  | 796 ++++++++++++++++++++-
 .../tasks/test_version_history_retention.py        | 659 ++++++++++++++++-
 11 files changed, 2142 insertions(+), 189 deletions(-)

diff --git a/UPDATING.md b/UPDATING.md
index fd50555be96..40ef0301342 100644
--- a/UPDATING.md
+++ b/UPDATING.md
@@ -1841,6 +1841,8 @@ Authorization reuses the resource's `can_read` permission 
and per-object `raise_
 
 Entity version history (the `version_transaction` / `*_version` shadow tables 
that back version capture) is aged out by a nightly Celery beat task, 
`version_history.prune_old_versions` 
(`superset.tasks.version_history_retention`).
 
+**Scheduled-run cap (behavior change):** 
`VERSION_HISTORY_PRUNE_MAX_TRANSACTIONS_PER_RUN` defaults to 1000 whole 
prunable transactions per invocation; associated shadow and change rows remain 
atomic. `0` or `None` restores unlimited runs. Invalid values skip the task 
before deletion. Capped results report `cap_reached`, `remaining_eligible`, and 
`remaining_count_complete`; the remainder is a lower bound from one candidate 
window after the live scan's stopping point when the probe does no [...]
+
 | Key | Default | Purpose |
 |---|---|---|
 | `VERSION_HISTORY_RETENTION_DAYS` | `30` | Version rows whose owning 
`version_transaction.issued_at` is older than this many days are pruned. Each 
entity's live row (`end_transaction_id IS NULL`) is always preserved, as are 
the live rows of its children and associations; closed historical rows 
(including the baseline) age out. `0` disables pruning; `-1` makes historical 
rows eligible on the next scheduled run. Other negative values are invalid and 
skip pruning. |
@@ -1864,6 +1866,8 @@ Soft-deleted dashboards, charts, and datasets are now 
permanently removed after
 
 Purging is **live by default** (`SOFT_DELETE_PURGE_DRY_RUN=False`), so the 
retention promise above is real on a stock deployment. Set it to `True` to have 
the task log `would_purge` counts and delete nothing — the lever is retained, 
so an operator can return to dry-run at any time. Note `would_purge` is an 
**upper bound** — it counts every entity past the retention window without 
evaluating deletion blockers, so a real run may purge fewer (entities 
referenced by report schedules or set a [...]
 
+**Scheduled-run cap (behavior change):** `SOFT_DELETE_PURGE_MAX_PER_RUN` 
defaults to 1000 successful root-entity purges across charts, dashboards, and 
datasets per invocation. One root plus its cascade counts as one; physical row 
counts are separate. The cap bounds committed deletions, not candidate 
evaluations: blocked roots are re-evaluated on every run, so a blocked-heavy 
backlog can still require substantial work. Set `0` or `None` for unlimited 
scheduled purging. Invalid values skip [...]
+
 Deployments that replace the default `CELERY_CONFIG` must ensure workers 
register `superset.tasks.deletion_retention` and schedule the 
`deletion_retention.purge_soft_deleted` task themselves. The shipped Docker 
development config uses `imports` and includes both entries. While 
`SOFT_DELETE` is statically enabled, a missing beat entry logs a startup 
warning; when the override explicitly defines `imports`, a missing purge module 
is also reported.
 
 Operators can immediately erase a specific entity for compliance (GDPR) via 
`superset deletion-retention force-purge --uuid <uuid>`; this applies legacy 
hard-delete semantics — a live chart referencing a force-purged dataset is left 
without a datasource until re-pointed (the chart is not modified), and it 
purges the named entity even when it was never soft-deleted. Every scheduled 
evaluation writes a provisional, content-free record to the new 
`purge_audit_log` table before the cascade s [...]
diff --git a/docs/docs/using-superset/recently-archived.mdx 
b/docs/docs/using-superset/recently-archived.mdx
index da8c5d8c687..b64e94f9a90 100644
--- a/docs/docs/using-superset/recently-archived.mdx
+++ b/docs/docs/using-superset/recently-archived.mdx
@@ -292,6 +292,43 @@ destructive when it runs. Set it to `True` in 
`superset_config.py` and restart
 the relevant processes to log what the task *would* purge without deleting
 anything. Validate the eligible backlog before enabling live purging or
 shortening a window; the first live run can process accumulated old archives.
+`SOFT_DELETE_PURGE_MAX_PER_RUN` defaults to `1000` root charts, dashboards, or
+datasets per scheduled run. One root's complete cascade counts as one and is
+never split to fit the cap. Set `0` or `None` in `superset_config.py` to remove
+the cap. It bounds committed deletions, not candidate evaluations: blocked 
roots
+are re-evaluated every run, so a blocked-heavy backlog can still require
+substantial work. A negative, boolean, string, or fractional value stops the
+scheduled task without purging. With a positive cap configured, a live run
+reports `cap_reached` and `remaining_eligible` for aged roots remaining across
+all supported models. In unlimited mode, the cap and remainder fields and
+their gauges are not emitted. The capped remainder includes
+roots that a reference blocker may prevent from purging. A remainder-count 
failure,
+a model scan failure, or an uncertain commit sets `remaining_eligible` to 
`None`
+and `remaining_count_complete` to `False`; this can happen even when no purge 
has
+been confirmed. Confirmed purge totals are retained. Inspect `scan_failures`,
+`commit_uncertain`, and the `deletion_retention.remainder_count_failed` counter
+and task logs to distinguish those cases. Dry-run scans the full
+eligible backlog regardless of the cap, writes nothing, and reports
+`eligible_backlog` plus `estimated_capped_runs`; the estimate assumes all
+eligible roots can be purged. If a model scan fails, `would_purge` contains 
only
+the counts collected before that failure; `backlog_count_complete` is `False`
+and both backlog estimates are `None`. If the database does not acknowledge a
+root purge commit, the task stops without processing another root and reports
+`commit_uncertain`. The possible deletion reserves a cap slot, its audit row
+remains pending for reconciliation, and the remainder count is incomplete.
+A commit the database rejects on a live connection is instead recorded as a
+failed attempt for that root, counted in `cascade_failures`, and the task
+continues with other eligible roots under the same cap.
+The operator's `force-purge` CLI remains uncapped.
+
+With the default daily schedule and cap, scheduled capacity is 1,000 root
+entities per day, not 1,000 physical rows. A backlog drains only when 
successful
+purges exceed newly eligible roots: at that throughput, 1,500 newly eligible
+roots per day add 500 to the backlog per day. Size 
`SOFT_DELETE_PURGE_MAX_PER_RUN` in
+`superset_config.py`, or increase the task's beat frequency, above eligible 
inflow
+with catch-up headroom. Repeated `cap_reached=True` results warrant checking 
the
+backlog trend and capacity. Blocked roots and failures can reduce actual 
throughput;
+increasing the cap alone does not remove reference blockers.
 
 ### Running retention tasks
 
diff --git a/docs/docs/using-superset/version-history.mdx 
b/docs/docs/using-superset/version-history.mdx
index 1986cf30a07..3f7cc8c63e0 100644
--- a/docs/docs/using-superset/version-history.mdx
+++ b/docs/docs/using-superset/version-history.mdx
@@ -96,6 +96,33 @@ History retention is configured independently with 
`VERSION_HISTORY_RETENTION_DA
 in `superset_config.py` or the environment variable of the same name. Its 
default
 is 30 days; set it to `0` to disable pruning. The scheduled retention task 
preserves
 live versions and prunes eligible closed history older than the configured 
window.
+`VERSION_HISTORY_PRUNE_MAX_TRANSACTIONS_PER_RUN` defaults to `1000` whole
+transactions per scheduled run. The transaction's shadow rows and change 
records
+are removed together; the cap counts transactions, not physical rows. Set the
+cap to `0` or `None` in `superset_config.py` for an unlimited run. Negative,
+boolean, string, and fractional caps are invalid and stop the task without
+deleting history. A capped run reports `cap_reached`, `remaining_eligible`, and
+`remaining_count_complete`. The remainder is exact when the bounded probe
+covers the backlog; otherwise it is a lower bound from its first 1,000 aged
+transaction candidates after the live scan's stopping point. If that probe
+fails after a successful prune, the remainder is `None` without hiding the
+committed deletion totals. A backlog drains only when successful pruning 
exceeds
+the rate at which transactions become eligible. With the default daily schedule
+and cap, scheduled capacity is 1,000 transactions per day. At that throughput,
+an eligible inflow of 1,500 per day grows the backlog by 500 per day. Size the 
cap in
+`superset_config.py`, or increase the task's beat frequency, to exceed eligible
+inflow and leave capacity for catch-up. Repeated `cap_reached=True` results 
warrant
+checking the backlog trend and available capacity; read them alongside 
remainder
+completeness and task errors. Live-history exclusions can reduce actual 
throughput.
+If a database commit's outcome is uncertain, the task stops rather than
+retrying that window, so it cannot spend another cap allowance in the same
+run. The task reports an error because it cannot assert how many transactions
+were committed. A definitive `OperationalError`, including one rejected at
+commit with a valid connection, may retry up to three attempts.
+Monitor the
+`superset.versioning.retention.remaining_count_complete` gauge alongside the
+`remaining_eligible_at_least` gauge so a lower-bound zero is not read as an
+empty backlog.
 The released `SUPERSET_VERSION_HISTORY_RETENTION_DAYS` key remains a deprecated
 fallback: its positive window is preserved and any nonpositive value still
 disables pruning. Migrate a legacy `-1` or other negative disable to the new
@@ -121,7 +148,11 @@ one of those transactions: closed rows can be removed when 
their closing
 transaction expires. Retention can therefore remove history used by previews
 and restores; version history is not a substitute for backups.
 
-There is no dry-run switch for this task. Neither `VERSION_HISTORY` nor
+Set `VERSION_HISTORY_PRUNE_DRY_RUN = True` in `superset_config.py` to scan the
+entire eligible backlog without deleting anything. The result includes
+`eligible_backlog` and `estimated_capped_runs`; a live-anchor transaction is
+excluded from both. The dry-run setting must be a boolean; an invalid value
+skips the scheduled task without deleting history. Neither `VERSION_HISTORY` 
nor
 `ENABLE_VERSIONING_CAPTURE` disables pruning of existing history. To preserve
 history while the feature is off, set the retention value to `0` and leave the
 task's beat schedule entry in place: removing the entry logs a missing-schedule
diff --git a/superset/config.py b/superset/config.py
index f311d533331..176ccf6c772 100644
--- a/superset/config.py
+++ b/superset/config.py
@@ -1080,6 +1080,9 @@ SOFT_DELETE_RETENTION_DAYS_FUNC: Callable[[], int] | None 
= None
 # built-in chart, dashboard and dataset roots are never affected.
 PURGE_POLICIES_FUNC: Callable[[], Any] | None = None
 SOFT_DELETE_PURGE_DRY_RUN: bool = False
+# Maximum committed root purges per scheduled run; 0 or None is unlimited.
+# Set an int in superset_config.py; this key has no automatic environment 
parsing.
+SOFT_DELETE_PURGE_MAX_PER_RUN: int | None = 1000
 
 # Retention policy for the purge audit log itself (the durable evidence the
 # purge task writes). Pruning is deletion-only and scheduled
@@ -1908,6 +1911,11 @@ def _normalize_version_history_retention_days(value: 
object, *, legacy: bool) ->
 
 
 VERSION_HISTORY_RETENTION_DAYS: int = _parse_version_history_retention_days()
+# Maximum committed transactions pruned per scheduled run; 0 or None is 
unlimited.
+# Set an int in superset_config.py; this key has no automatic environment 
parsing.
+VERSION_HISTORY_PRUNE_MAX_TRANSACTIONS_PER_RUN: int | None = 1000
+# Set a bool in superset_config.py; this key has no automatic environment 
parsing.
+VERSION_HISTORY_PRUNE_DRY_RUN: bool = False
 _version_history_retention_seed: int = VERSION_HISTORY_RETENTION_DAYS
 
 # Sentinel for "key not configured at all", distinct from any configured value
diff --git a/superset/tasks/deletion_retention.py 
b/superset/tasks/deletion_retention.py
index 6db107cec7d..5ff3e879855 100644
--- a/superset/tasks/deletion_retention.py
+++ b/superset/tasks/deletion_retention.py
@@ -21,22 +21,23 @@ that ages out version rows while keeping the live entity, 
this removes
 entities that are already soft-deleted. For each
 ``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.
+cascade per entity, in bounded id-ordered batches. Safe to repeat: each capped
+run purges at most the configured number of root entities. Successive runs
+converge by draining the purgeable backlog; blocked roots can remain, and
+newly eligible rows can add to that backlog.
 """
 
 from __future__ import annotations
 
 import logging
 from collections.abc import Iterator
-from dataclasses import dataclass, field
 from datetime import datetime, timedelta
-from typing import Any, cast
+from typing import Any, cast, NamedTuple
 from uuid import UUID
 
 import sqlalchemy as sa
 from flask import current_app
+from sqlalchemy.exc import DBAPIError
 
 from superset import db
 from superset.commands.deletion_retention import audit, prune_audit
@@ -57,6 +58,7 @@ from superset.models.helpers import (
     skip_visibility_filter,
     SoftDeleteMixin,
 )
+from superset.tasks.retention_cap import validate_retention_cap
 
 logger: logging.Logger = logging.getLogger(__name__)
 
@@ -71,6 +73,15 @@ def _soft_delete_models() -> list[type[SoftDeleteMixin]]:
     return list(SoftDeleteMixin._registered_subclasses)  # noqa: SLF001
 
 
+def _ordered_purge_models(cutoff: datetime) -> list[type[SoftDeleteMixin]]:
+    """Rotate daily priority so one busy model cannot starve later models."""
+    models: list[type[SoftDeleteMixin]] = _soft_delete_models()
+    if not models:
+        return models
+    start: int = cutoff.toordinal() % len(models)
+    return models[start:] + models[:start]
+
+
 def _model_table(model: type[SoftDeleteMixin]) -> sa.Table:
     """Return SQLAlchemy table metadata for a registered soft-delete model."""
     return cast(sa.Table, cast(Any, model).__table__)
@@ -133,35 +144,73 @@ def _report_model_counts(outcome: str, counts: dict[str, 
int]) -> None:
         )
 
 
-@dataclass
-class _PassTotals:
-    """What one pass over the soft-delete roots produced."""
+def _count_eligible(model: type[SoftDeleteMixin], cutoff: datetime) -> int:
+    """Count aged roots, including roots that a later cascade may block."""
+    table: sa.Table = _model_table(model)
+    with skip_visibility_filter(db.session, model):
+        return int(
+            db.session.scalar(
+                sa.select(sa.func.count())
+                .select_from(table)
+                .where(table.c.deleted_at.is_not(None), table.c.deleted_at < 
cutoff)
+            )
+            or 0
+        )
 
-    purged: dict[str, int] = field(default_factory=dict)
-    would_purge: dict[str, int] = field(default_factory=dict)
-    unsupported: dict[str, int] = field(default_factory=dict)
-    cascade_failures: int = 0
-    blocked: int = 0
+
+class _PurgeScan(NamedTuple):
+    """Counts and supported models collected across one purge invocation."""
+
+    purged: dict[str, int]
+    would_purge: dict[str, int]
+    unsupported_models: dict[str, int]
+    failures: int
+    blocked: int
+    remaining_budget: int | None
+    supported_models: list[type[SoftDeleteMixin]]
     scan_failures: int = 0
-    #: Roots this pass actually reached, so a run where every one of them
-    #: failed can be told apart from a run where one did.
     attempted: int = 0
+    commit_uncertain: bool = False
+
+
+class _PurgeModelResult(NamedTuple):
+    """Named per-root counts, including an indeterminate commit attempt."""
+
+    purged: int
+    would_purge: int
+    failures: int
+    blocked: int
+    scan_failures: int
+    commit_uncertain: bool = False
 
 
-def _purge_roots(cutoff: datetime, dry_run: bool) -> _PassTotals:
-    """Process each registered root, isolating one root's failure from the 
rest."""
-    totals = _PassTotals()
-    for model in _soft_delete_models():
-        totals.attempted += 1
-        # The table name is itself read off the model, so resolving it belongs
-        # inside the guard: a root that cannot supply one would otherwise end
-        # the pass here, above the isolation meant to contain it. The class
-        # name stands in until it is known, for the failure log.
-        entity_type = model.__name__
+class _PurgeCommitUncertainError(RuntimeError):
+    """The database did not acknowledge whether a root purge committed."""
+
+
+def _scan_purge_models(
+    cutoff: datetime, dry_run: bool, max_per_run: int | None
+) -> _PurgeScan:
+    """Apply one shared root budget across every supported model."""
+    purged: dict[str, int] = {}
+    would_purge: dict[str, int] = {}
+    unsupported_models: dict[str, int] = {}
+    failures: int = 0
+    blocked: int = 0
+    scan_failures: int = 0
+    attempted: int = 0
+    commit_uncertain: bool = False
+    remaining_budget: int | None = max_per_run
+    supported_models: list[type[SoftDeleteMixin]] = []
+
+    for model in _ordered_purge_models(cutoff):
+        attempted += 1
+        entity_type: str = model.__name__
         try:
             entity_type = _model_table_name(model)
             if model not in purge_policy_registry():
-                totals.unsupported[entity_type] = 1
+                attempted -= 1
+                unsupported_models[entity_type] = 1
                 logger.warning(
                     "deletion_retention: skipping %s: no purge policy", 
entity_type
                 )
@@ -169,16 +218,19 @@ def _purge_roots(cutoff: datetime, dry_run: bool) -> 
_PassTotals:
                     f"{_METRIC_PREFIX}.unsupported_models.{entity_type}"
                 )
                 continue
-            purged_n, would_n, failed_n, blocked_n, scan_failed_n = 
_purge_model(
-                model, cutoff, dry_run
+            supported_models.append(model)
+            if remaining_budget == 0 and not dry_run:
+                attempted -= 1
+                continue
+            counts: _PurgeModelResult = _purge_model(
+                model,
+                cutoff,
+                dry_run,
+                max_per_run=None if dry_run else remaining_budget,
             )
         except Exception:  # pylint: disable=broad-except
-            # One root must not cost the others their run. _purge_model keeps
-            # its own counts when a scan fails part-way; this guards what is
-            # left -- resolving the registry, and anything else before the
-            # first page.
             db.session.rollback()  # pylint: disable=consider-using-transaction
-            totals.scan_failures += 1
+            scan_failures += 1
             stats_logger_manager.instance.incr(
                 f"{_METRIC_PREFIX}.scan_failures.{entity_type}"
             )
@@ -186,18 +238,87 @@ def _purge_roots(cutoff: datetime, dry_run: bool) -> 
_PassTotals:
                 "deletion_retention: %s could not be processed", entity_type
             )
             continue
-        if would_n:
-            totals.would_purge[entity_type] = would_n
-        if purged_n:
-            totals.purged[entity_type] = purged_n
-        totals.cascade_failures += failed_n
-        totals.blocked += blocked_n
-        totals.scan_failures += scan_failed_n
-        if scan_failed_n:
+        if remaining_budget is not None and not dry_run:
+            # Reserve the possible commit even though it is not confirmed.
+            remaining_budget -= counts.purged + int(counts.commit_uncertain)
+        if counts.would_purge:
+            would_purge[entity_type] = counts.would_purge
+        if counts.purged:
+            purged[entity_type] = counts.purged
+        failures += counts.failures
+        blocked += counts.blocked
+        scan_failures += counts.scan_failures
+        if counts.scan_failures:
             stats_logger_manager.instance.incr(
                 f"{_METRIC_PREFIX}.scan_failures.{entity_type}"
             )
-    return totals
+        if counts.commit_uncertain:
+            commit_uncertain = True
+            break
+
+    return _PurgeScan(
+        purged=purged,
+        would_purge=would_purge,
+        unsupported_models=unsupported_models,
+        failures=failures,
+        blocked=blocked,
+        remaining_budget=remaining_budget,
+        supported_models=supported_models,
+        scan_failures=scan_failures,
+        attempted=attempted,
+        commit_uncertain=commit_uncertain,
+    )
+
+
+def _add_purge_cap_stats(
+    stats: dict[str, Any],
+    cutoff: datetime,
+    scan: _PurgeScan,
+    max_per_run: int | None,
+) -> None:
+    """Count remaining roots without masking a committed purge on failure."""
+    if max_per_run is None:
+        return
+    remaining_eligible: int | None
+    count_complete: bool = scan.scan_failures == 0 and not 
scan.commit_uncertain
+    if count_complete:
+        try:
+            remaining_eligible = sum(
+                _count_eligible(model, cutoff) for model in 
scan.supported_models
+            )
+        except Exception:  # pylint: disable=broad-except
+            logger.warning(
+                "deletion_retention: remainder count failed after purge",
+                exc_info=True,
+            )
+            try:
+                db.session.rollback()  # pylint: 
disable=consider-using-transaction
+            except Exception:  # pylint: disable=broad-except
+                logger.exception("deletion_retention: remainder count rollback 
failed")
+            stats_logger_manager.instance.incr(
+                f"{_METRIC_PREFIX}.remainder_count_failed"
+            )
+            remaining_eligible = None
+            count_complete = False
+    else:
+        remaining_eligible = None
+    cap_reached: bool = scan.remaining_budget == 0
+    stats.update(
+        max_per_run=max_per_run,
+        cap_reached=cap_reached,
+        remaining_eligible=remaining_eligible,
+        remaining_count_complete=count_complete,
+    )
+    if remaining_eligible is not None:
+        stats_logger_manager.instance.gauge(
+            f"{_METRIC_PREFIX}.remaining_eligible", remaining_eligible
+        )
+    stats_logger_manager.instance.gauge(
+        f"{_METRIC_PREFIX}.remaining_count_complete", int(count_complete)
+    )
+    stats_logger_manager.instance.gauge(
+        f"{_METRIC_PREFIX}.cap_reached", int(cap_reached)
+    )
 
 
 def _report_total_outage(every_root_failed: bool) -> None:
@@ -217,8 +338,11 @@ def _report_total_outage(every_root_failed: bool) -> None:
     stats_logger_manager.instance.incr(f"{_METRIC_PREFIX}.failed")
 
 
-def _purge_impl(window_days: int, dry_run: bool) -> dict[str, Any]:
+def _purge_impl(
+    window_days: int, dry_run: bool, max_per_run: int | None = None
+) -> dict[str, Any]:
     """Run one purge pass across all soft-delete models."""
+    max_per_run = validate_retention_cap(max_per_run, 
"SOFT_DELETE_PURGE_MAX_PER_RUN")
     if window_days == 0 or window_days < -1:
         logger.info("deletion_retention: window is disabled or invalid; 
skipping")
         stats_logger_manager.instance.incr(f"{_METRIC_PREFIX}.skipped")
@@ -236,62 +360,76 @@ def _purge_impl(window_days: int, dry_run: bool) -> 
dict[str, Any]:
         else datetime.now() - timedelta(days=window_days)
     )
     _reconcile_unless_dry_run(dry_run)
-    totals: _PassTotals = _purge_roots(cutoff, dry_run)
-    purged = totals.purged
-    would_purge = totals.would_purge
-    unsupported_models = totals.unsupported
-    failures = totals.cascade_failures
-    blocked = totals.blocked
-    scan_failures = totals.scan_failures
-    # A root with no policy was never scanned, so it is not a failure and
-    # does not count toward "every root failed" -- which is what tells a
-    # metadata-database outage apart from one root misbehaving.
-    scannable: int = totals.attempted - len(unsupported_models)
-    every_root_failed: bool = scannable > 0 and scan_failures == scannable
+    scan: _PurgeScan = _scan_purge_models(cutoff, dry_run, max_per_run)
+    every_root_failed: bool = (
+        scan.attempted > 0 and scan.scan_failures == scan.attempted
+    )
 
     if dry_run:
-        _report_model_counts("would_purge", would_purge)
-        logger.info("deletion_retention: DRY RUN would_purge=%s", would_purge)
-        if scan_failures:
-            stats_logger_manager.instance.gauge(
-                f"{_METRIC_PREFIX}.scan_failures", scan_failures
+        _report_model_counts("would_purge", scan.would_purge)
+        logger.info("deletion_retention: DRY RUN would_purge=%s", 
scan.would_purge)
+        backlog: int | None = (
+            sum(scan.would_purge.values()) if scan.scan_failures == 0 else None
+        )
+        estimated_runs: int | None = None
+        if backlog is not None:
+            estimated_runs = (
+                (backlog + max_per_run - 1) // max_per_run
+                if max_per_run is not None
+                else int(backlog > 0)
             )
-        _report_total_outage(every_root_failed)
-        return {
+        dry_stats: dict[str, Any] = {
             "dry_run": 1,
-            "would_purge": would_purge,
-            "unsupported_models": unsupported_models,
-            "scan_failures": scan_failures,
+            "would_purge": scan.would_purge,
+            "unsupported_models": scan.unsupported_models,
+            "scan_failures": scan.scan_failures,
+            "eligible_backlog": backlog,
+            "backlog_count_complete": backlog is not None,
+            "max_per_run": max_per_run,
+            "estimated_capped_runs": estimated_runs,
         }
+        if scan.scan_failures:
+            stats_logger_manager.instance.gauge(
+                f"{_METRIC_PREFIX}.scan_failures", scan.scan_failures
+            )
+        _report_total_outage(every_root_failed)
+        return dry_stats
 
-    _report_model_counts("purged", purged)
-    if failures:
+    _report_model_counts("purged", scan.purged)
+    if scan.failures:
         
stats_logger_manager.instance.incr(f"{_METRIC_PREFIX}.cascade_failures")
-    if blocked:
+    if scan.blocked:
         stats_logger_manager.instance.gauge(
-            f"{_METRIC_PREFIX}.blocked_by_reference", blocked
+            f"{_METRIC_PREFIX}.blocked_by_reference", scan.blocked
         )
-    if scan_failures:
+    if scan.scan_failures:
         stats_logger_manager.instance.gauge(
-            f"{_METRIC_PREFIX}.scan_failures", scan_failures
+            f"{_METRIC_PREFIX}.scan_failures", scan.scan_failures
         )
+    if scan.commit_uncertain:
+        
stats_logger_manager.instance.incr(f"{_METRIC_PREFIX}.commit_uncertain")
+        stats_logger_manager.instance.incr(f"{_METRIC_PREFIX}.failed")
     _report_total_outage(every_root_failed)
-    stats = {
-        "purged": purged,
-        "cascade_failures": failures,
-        "blocked_by_reference": blocked,
-        "unsupported_models": unsupported_models,
-        "scan_failures": scan_failures,
+    stats: dict[str, Any] = {
+        "purged": scan.purged,
+        "cascade_failures": scan.failures,
+        "blocked_by_reference": scan.blocked,
+        "unsupported_models": scan.unsupported_models,
+        "scan_failures": scan.scan_failures,
+        "commit_uncertain": scan.commit_uncertain,
     }
+    _add_purge_cap_stats(stats, cutoff, scan, max_per_run)
     logger.info("deletion_retention: %s", stats)
     return stats
 
 
-def _purge_model(
-    model: type[SoftDeleteMixin], cutoff: datetime, dry_run: bool
-) -> tuple[int, int, int, int, int]:
-    """Process one model's eligible rows. Returns ``(purged, would_purge,
-    failures, blocked, scan_failures)``. A single entity's blocked/failed
+def _purge_model(  # noqa: C901
+    model: type[SoftDeleteMixin],
+    cutoff: datetime,
+    dry_run: bool,
+    max_per_run: int | None = None,
+) -> _PurgeModelResult:
+    """Process one model's eligible rows. A single entity's blocked/failed
     cascade never aborts the batch.
 
     A failure in the eligible-id scan itself -- a column it cannot read, a
@@ -302,18 +440,30 @@ def _purge_model(
     """
     entity_type = _model_table_name(model)
     purged = would = failures = blocked = scan_failures = 0
+    commit_uncertain: bool = False
     try:
         for id_batch in _iter_eligible_ids(model, cutoff, _BATCH):
             if dry_run:
                 would += len(id_batch)
                 continue
             for entity_id in id_batch:
+                if max_per_run is not None and purged >= max_per_run:
+                    break
                 try:
                     result = _purge_one(model, entity_id, cutoff)
                     if result is not None and result.purged:
                         purged += 1
                     elif result is not None and result.blocked_reason is not 
None:
                         blocked += 1
+                except _PurgeCommitUncertainError:
+                    commit_uncertain = True
+                    logger.exception(
+                        "deletion_retention: commit outcome unknown for %s 
id=%s; "
+                        "deferring remaining roots",
+                        entity_type,
+                        entity_id,
+                    )
+                    break
                 except Exception:  # pylint: disable=broad-except
                     db.session.rollback()  # pylint: 
disable=consider-using-transaction
                     failures += 1
@@ -322,11 +472,21 @@ def _purge_model(
                         entity_type,
                         entity_id,
                     )
+            if commit_uncertain or (max_per_run is not None and purged >= 
max_per_run):
+                break
     except Exception:  # pylint: disable=broad-except
-        db.session.rollback()  # pylint: disable=consider-using-transaction
         scan_failures = 1
         logger.exception("deletion_retention: scan failed for %s", entity_type)
-    return purged, would, failures, blocked, scan_failures
+        try:
+            db.session.rollback()  # pylint: disable=consider-using-transaction
+        except Exception:  # pylint: disable=broad-except
+            # Earlier roots already committed; preserve their budget 
accounting.
+            logger.exception(
+                "deletion_retention: scan rollback failed for %s", entity_type
+            )
+    return _PurgeModelResult(
+        purged, would, failures, blocked, scan_failures, commit_uncertain
+    )
 
 
 def _finalize_blocked(record_id: UUID | None, blocker: BlockerReason) -> None:
@@ -342,6 +502,48 @@ def _finalize_blocked(record_id: UUID | None, blocker: 
BlockerReason) -> None:
         )
 
 
+def _confirm_committed_purge(record_id: UUID | None, result: CascadeResult) -> 
None:
+    """Keep a committed root counted even if audit finalization fails."""
+    try:
+        audit.confirm(
+            record_id,
+            affected_referrers=result.dangling_chart_uuids,
+            removed_dashboard_slices=result.removed_dashboard_slices,
+        )
+    except Exception:  # pylint: disable=broad-except
+        # The audit remains pending for its normal reconciliation path.
+        logger.exception(
+            "deletion_retention: audit finalization failed after committed 
purge"
+        )
+
+
+def _commit_purge_root(record_id: UUID | None) -> None:
+    """Commit a purge, keeping uncertain outcomes pending for 
reconciliation."""
+    try:
+        db.session.commit()  # pylint: disable=consider-using-transaction
+    except Exception as exc:
+        try:
+            db.session.rollback()  # pylint: disable=consider-using-transaction
+        except Exception:  # pylint: disable=broad-except
+            logger.exception("deletion_retention: rollback after commit error 
failed")
+            raise _PurgeCommitUncertainError(
+                "root purge rollback outcome unknown"
+            ) from exc
+        if isinstance(exc, DBAPIError) and not exc.connection_invalidated:
+            # The valid connection rejected the commit; the root can be
+            # recorded as failed without consuming the confirmed purge cap.
+            logger.warning(
+                "deletion_retention: definitive root purge commit failure 
(%s): %s",
+                type(exc).__name__,
+                exc.orig,
+            )
+            audit.fail(record_id)
+            raise
+        # A lost acknowledgement can follow a successful commit. Keep the
+        # audit pending and defer remaining roots for reconciliation.
+        raise _PurgeCommitUncertainError("root purge commit outcome unknown") 
from exc
+
+
 def _purge_one(
     model: type[SoftDeleteMixin], entity_id: int, cutoff: datetime
 ) -> CascadeResult | None:
@@ -403,19 +605,16 @@ def _purge_one(
         # pending association statements during flush/commit, so the
         # block's exit-time trim must run first or a session carrying
         # versioned state would write the purge-queued shadows anyway.
-        # Commit/rollback are managed manually so audit.fail() can
-        # record the outcome after the purge transaction resolves.
-        db.session.commit()  # pylint: disable=consider-using-transaction
+        # Commit/rollback are managed manually so audit.fail() can record a
+        # definitive pre-commit failure after the purge transaction resolves.
+        db.session.flush()
     except Exception:
         db.session.rollback()  # pylint: disable=consider-using-transaction
         audit.fail(record_id)
         raise
+    _commit_purge_root(record_id)
     if result.purged:
-        audit.confirm(
-            record_id,
-            affected_referrers=result.dangling_chart_uuids,
-            removed_dashboard_slices=result.removed_dashboard_slices,
-        )
+        _confirm_committed_purge(record_id, result)
     elif result.blocker is not None:
         _finalize_blocked(record_id, result.blocker)
     else:
@@ -498,7 +697,8 @@ def prune_purge_audit() -> dict[str, Any]:
 def purge_soft_deleted() -> dict[str, Any]:
     """Beat entry point. Resolves the window live, honors the SOFT_DELETE
     rollout gate and dry-run flag, and isolates failures so one bad run does
-    not poison the schedule."""
+    not poison the schedule. The cap bounds committed root deletions, not
+    candidate evaluations; blocked roots are re-evaluated on every run."""
     # While the temporary SOFT_DELETE rollout gate is off the delete path
     # writes no ``deleted_at`` rows, so the task already no-ops; check the gate
     # explicitly for clarity (the check is removed when the gate is).
@@ -508,7 +708,16 @@ def purge_soft_deleted() -> dict[str, Any]:
     window_days = resolve_retention_window()
     dry_run = bool(current_app.config.get("SOFT_DELETE_PURGE_DRY_RUN", True))
     try:
-        return _purge_impl(window_days, dry_run)
+        max_per_run: int | None = validate_retention_cap(
+            current_app.config.get("SOFT_DELETE_PURGE_MAX_PER_RUN", 1000),
+            "SOFT_DELETE_PURGE_MAX_PER_RUN",
+        )
+    except ValueError:
+        logger.warning("deletion_retention: invalid purge cap; skipping")
+        
stats_logger_manager.instance.incr(f"{_METRIC_PREFIX}.skipped_invalid_cap")
+        return {"skipped_invalid_cap": 1}
+    try:
+        return _purge_impl(window_days, dry_run, max_per_run=max_per_run)
     except Exception:  # pylint: disable=broad-except
         logger.exception("deletion_retention.purge_soft_deleted: task failed")
         stats_logger_manager.instance.incr(f"{_METRIC_PREFIX}.failed")
diff --git a/superset/tasks/retention_cap.py b/superset/tasks/retention_cap.py
new file mode 100644
index 00000000000..dda3acc2f8d
--- /dev/null
+++ b/superset/tasks/retention_cap.py
@@ -0,0 +1,32 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+"""Validate per-invocation retention budgets before destructive work."""
+
+from __future__ import annotations
+
+
+def validate_retention_cap(value: object, key: str) -> int | None:
+    """Return a positive unit cap, or ``None`` for explicit unlimited mode.
+
+    Invalid configuration raises so scheduled cleanup fails closed. Booleans
+    and numeric strings are not valid integer budgets.
+    """
+    if value is None or (type(value) is int and value == 0):
+        return None
+    if type(value) is not int or value < 0:
+        raise ValueError(f"{key} must be a nonnegative integer or None")
+    return value
diff --git a/superset/tasks/version_history_retention.py 
b/superset/tasks/version_history_retention.py
index a8d6cf44679..6b77a790abd 100644
--- a/superset/tasks/version_history_retention.py
+++ b/superset/tasks/version_history_retention.py
@@ -40,7 +40,9 @@ otherwise-expired history. Every other prunable transaction 
is dropped, and
 its ``version_changes`` rows cascade via the FK.
 
 Registered via ``CeleryConfig.beat_schedule`` in ``superset/config.py``.
-Idempotent: a second run prunes nothing.
+Safe to repeat: each capped run prunes at most the configured number of
+transactions. Successive runs converge by draining the remaining prunable
+backlog; newly eligible transactions can add to that backlog.
 """
 
 from __future__ import annotations
@@ -54,7 +56,7 @@ from typing import Any
 
 import sqlalchemy as sa
 from flask import current_app
-from sqlalchemy.exc import OperationalError
+from sqlalchemy.exc import DBAPIError, OperationalError
 
 from superset.config import (
     _MISSING_RETENTION,
@@ -62,6 +64,7 @@ from superset.config import (
     _version_history_retention_seed,
 )
 from superset.extensions import celery_app, db, stats_logger_manager
+from superset.tasks.retention_cap import validate_retention_cap
 from superset.utils.dates import naive_utcnow
 
 logger: logging.Logger = logging.getLogger(__name__)
@@ -351,8 +354,56 @@ _RETRY_BACKOFF_FACTOR: int = 4
 _METRIC_PREFIX: str = "superset.versioning.retention"
 
 
-def _run_prune_pass(
+def _live_bearing_tables(tables: ShadowTables) -> list[sa.Table]:
+    """Return every shadow table that can anchor a live transaction."""
+    live_bearing_tables: list[sa.Table] = [*tables.parent, *tables.child]
+    if tables.m2m is not None:
+        live_bearing_tables.append(tables.m2m)
+    return live_bearing_tables
+
+
+def _count_prunable(cutoff: datetime, tables: ShadowTables) -> int:
+    """Count the full eligible transaction backlog without writing rows."""
+    total: int = 0
+    after_id: int = 0
+    live_bearing_tables: list[sa.Table] = _live_bearing_tables(tables)
+    conn: sa.engine.Connection
+    while True:
+        with db.engine.connect() as conn:
+            window: _PruneWindow = _resolve_prune_window(
+                conn, cutoff, live_bearing_tables, after_id, _MAX_PRUNE_BATCH
+            )
+        total += len(window.prunable)
+        if window.candidate_count < _MAX_PRUNE_BATCH:
+            return total
+        after_id = window.max_candidate_id
+
+
+def _probe_prunable(
     cutoff: datetime, tables: ShadowTables, after_id: int = 0
+) -> tuple[int, bool]:
+    """Count one candidate window after the live run's scan cursor."""
+    conn: sa.engine.Connection
+    with db.engine.connect() as conn:
+        window: _PruneWindow = _resolve_prune_window(
+            conn, cutoff, _live_bearing_tables(tables), after_id, 
_MAX_PRUNE_BATCH
+        )
+    return len(window.prunable), window.candidate_count < _MAX_PRUNE_BATCH
+
+
+class _PruneCommitUncertainError(RuntimeError):
+    """The database did not acknowledge whether a prune pass committed."""
+
+
+class _PruneRollbackFailedError(RuntimeError):
+    """A prune transaction could not be rolled back safely."""
+
+
+def _run_prune_pass(
+    cutoff: datetime,
+    tables: ShadowTables,
+    after_id: int = 0,
+    max_prune: int | None = None,
 ) -> dict[str, Any]:
     """One SERIALIZABLE pass over a single id-ordered window of candidate
     transactions starting after ``after_id``. The caller wraps this in
@@ -364,44 +415,65 @@ def _run_prune_pass(
     # parents: children and the M2M association live on independent
     # validity lifecycles and may anchor a still-live row at an older
     # transaction than the parent's current live row.
-    live_bearing_tables: list[sa.Table] = [*tables.parent, *tables.child]
-    if tables.m2m is not None:
-        live_bearing_tables.append(tables.m2m)
+    live_bearing_tables: list[sa.Table] = _live_bearing_tables(tables)
 
     # The Celery task runs outside the request-bound DB session, so we
     # use a fresh connection rather than ``db.session`` to avoid stepping
     # on web-request state.
-    with (
-        db.engine.connect().execution_options(isolation_level="SERIALIZABLE") 
as conn,
-        conn.begin(),
-    ):
-        window = _resolve_prune_window(
-            conn, cutoff, live_bearing_tables, after_id, _MAX_PRUNE_BATCH
-        )
-        tx_ids = window.prunable
-
-        parent_rows = _delete_for_transactions(conn, tables.parent, tx_ids)
-        child_rows = _delete_for_transactions(conn, tables.child, tx_ids)
-        m2m_rows = (
-            _delete_for_transactions(conn, [tables.m2m], tx_ids)
-            if tables.m2m is not None
-            else 0
-        )
-
-        # Drop the version_transaction rows themselves. ON DELETE
-        # CASCADE on version_changes.transaction_id removes the
-        # associated change records automatically. Same SQLite bind-
-        # parameter chunking applies as the shadow deletes above.
-        tx_rows = 0
-        for chunk in _chunked(tx_ids, _TX_ID_CHUNK_SIZE):
-            tx_rows += (
-                conn.execute(
-                    sa.delete(tables.transaction).where(
-                        tables.transaction.c.id.in_(chunk)
-                    )
-                ).rowcount
-                or 0
+    with db.engine.connect().execution_options(isolation_level="SERIALIZABLE") 
as conn:
+        transaction: sa.engine.Transaction = conn.begin()
+        try:
+            window: _PruneWindow = _resolve_prune_window(
+                conn, cutoff, live_bearing_tables, after_id, _MAX_PRUNE_BATCH
             )
+            tx_ids: list[int] = window.prunable[:max_prune]
+
+            parent_rows: int = _delete_for_transactions(conn, tables.parent, 
tx_ids)
+            child_rows: int = _delete_for_transactions(conn, tables.child, 
tx_ids)
+            m2m_rows: int = (
+                _delete_for_transactions(conn, [tables.m2m], tx_ids)
+                if tables.m2m is not None
+                else 0
+            )
+
+            # Drop the version_transaction rows themselves. ON DELETE
+            # CASCADE on version_changes.transaction_id removes the
+            # associated change records automatically. Same SQLite bind-
+            # parameter chunking applies as the shadow deletes above.
+            tx_rows: int = 0
+            for chunk in _chunked(tx_ids, _TX_ID_CHUNK_SIZE):
+                tx_rows += (
+                    conn.execute(
+                        sa.delete(tables.transaction).where(
+                            tables.transaction.c.id.in_(chunk)
+                        )
+                    ).rowcount
+                    or 0
+                )
+        except Exception:
+            try:
+                transaction.rollback()  # pylint: 
disable=consider-using-transaction
+            except Exception as exc:
+                raise _PruneRollbackFailedError(
+                    "version prune rollback failed"
+                ) from exc
+            raise
+        try:
+            transaction.commit()  # pylint: disable=consider-using-transaction
+        except Exception as exc:
+            if isinstance(exc, DBAPIError) and not exc.connection_invalidated:
+                try:
+                    transaction.rollback()  # pylint: 
disable=consider-using-transaction
+                except Exception as rollback_exc:
+                    raise _PruneRollbackFailedError(
+                        "version prune rollback failed"
+                    ) from rollback_exc
+                raise
+            # A lost acknowledgement can follow a successful commit. Replaying
+            # the same cursor could prune beyond the per-run cap.
+            raise _PruneCommitUncertainError(
+                "version prune commit outcome unknown"
+            ) from exc
 
     return {
         "cutoff": cutoff.isoformat(),
@@ -415,24 +487,27 @@ def _run_prune_pass(
 
 
 def _run_pass_with_retry(
-    cutoff: datetime, tables: ShadowTables, after_id: int
+    cutoff: datetime,
+    tables: ShadowTables,
+    after_id: int,
+    max_prune: int | None = None,
 ) -> tuple[dict[str, Any], int]:
     """Run one window pass, retrying on serialization conflict. Returns
     ``(stats, retries_used)``; re-raises the ``OperationalError`` if all
     ``_MAX_RETRY_ATTEMPTS`` attempts conflict.
 
     Postgres surfaces conflicts as ``SerializationFailure`` (a subclass
-    of ``sqlalchemy.exc.OperationalError``). The catch is deliberately the
-    broader ``OperationalError`` — it also covers transient faults such as
-    SQLite's "database is locked" and dropped connections, all of which are
-    safe to retry because each pass is idempotent and runs in its own fresh
-    transaction. Without the inline retry a single conflict pushes the next
-    attempt 24h out (daily Celery beat), so under sustained write pressure
-    the prune could silently fail for days in a row.
+    of ``sqlalchemy.exc.OperationalError``). Definitive errors may retry
+    from a fresh transaction. A commit acknowledgement failure instead raises
+    ``_PruneCommitUncertainError`` and defers the rest of this invocation, 
because
+    replaying the same cursor could exceed the per-run cap.
     """
     for attempt in range(1, _MAX_RETRY_ATTEMPTS + 1):
         try:
-            return _run_prune_pass(cutoff, tables, after_id), attempt - 1
+            return (
+                _run_prune_pass(cutoff, tables, after_id, max_prune=max_prune),
+                attempt - 1,
+            )
         except OperationalError as exc:
             stats_logger_manager.instance.incr(f"{_METRIC_PREFIX}.retried")
             if attempt == _MAX_RETRY_ATTEMPTS:
@@ -455,7 +530,76 @@ def _run_pass_with_retry(
     raise AssertionError("unreachable")  # pragma: no cover
 
 
-def _prune_old_versions_impl(retention_days: int) -> dict[str, Any]:
+def _preview_prune(
+    cutoff: datetime, tables: ShadowTables, max_per_run: int | None
+) -> dict[str, Any]:
+    """Report the full prunable backlog without deleting transactions."""
+    backlog: int = _count_prunable(cutoff, tables)
+    estimated_runs: int = (
+        (backlog + max_per_run - 1) // max_per_run
+        if max_per_run is not None
+        else int(backlog > 0)
+    )
+    stats: dict[str, Any] = {
+        "dry_run": 1,
+        "cutoff": cutoff.isoformat(),
+        "eligible_backlog": backlog,
+        "max_per_run": max_per_run,
+        "estimated_capped_runs": estimated_runs,
+    }
+    logger.info("version_history_retention: DRY RUN %s", stats)
+    return stats
+
+
+def _add_prune_cap_stats(
+    stats: dict[str, Any],
+    cutoff: datetime,
+    tables: ShadowTables,
+    max_per_run: int | None,
+    after_id: int,
+) -> None:
+    """Probe bounded live-run backlog without masking committed deletions."""
+    if max_per_run is None:
+        return
+    cap_reached: bool = stats["pruned_transactions"] >= max_per_run
+    remaining_eligible: int | None = 0
+    count_complete: bool = True
+    if cap_reached:
+        try:
+            remaining_eligible, count_complete = _probe_prunable(
+                cutoff, tables, after_id
+            )
+        except Exception:  # pylint: disable=broad-except
+            logger.warning(
+                "version_history_retention: remainder probe failed after 
prune",
+                exc_info=True,
+            )
+            stats_logger_manager.instance.incr(
+                f"{_METRIC_PREFIX}.remainder_probe_failed"
+            )
+            remaining_eligible = None
+            count_complete = False
+    stats.update(
+        max_per_run=max_per_run,
+        cap_reached=cap_reached,
+        remaining_eligible=remaining_eligible,
+        remaining_count_complete=count_complete,
+    )
+    if remaining_eligible is not None:
+        stats_logger_manager.instance.gauge(
+            f"{_METRIC_PREFIX}.remaining_eligible_at_least", remaining_eligible
+        )
+    stats_logger_manager.instance.gauge(
+        f"{_METRIC_PREFIX}.remaining_count_complete", int(count_complete)
+    )
+    stats_logger_manager.instance.gauge(
+        f"{_METRIC_PREFIX}.cap_reached", int(cap_reached)
+    )
+
+
+def _prune_old_versions_impl(
+    retention_days: int, max_per_run: int | None = None, dry_run: bool = False
+) -> dict[str, Any]:
     """Pure-Python implementation of the prune. Split out from the
     Celery task wrapper so unit tests can call it directly without the
     Celery harness.
@@ -481,6 +625,9 @@ def _prune_old_versions_impl(retention_days: int) -> 
dict[str, Any]:
     24 hours out (daily Celery beat), and under sustained write
     pressure the prune can silently fail for many days in a row.
     """
+    max_per_run = validate_retention_cap(
+        max_per_run, "VERSION_HISTORY_PRUNE_MAX_TRANSACTIONS_PER_RUN"
+    )
     if retention_days == 0 or retention_days < -1:
         logger.info(
             "version_history_retention: retention disabled or invalid; 
skipping",
@@ -502,6 +649,9 @@ def _prune_old_versions_impl(retention_days: int) -> 
dict[str, Any]:
     if retention_days != -1:
         cutoff -= timedelta(days=retention_days)
 
+    if dry_run:
+        return _preview_prune(cutoff, tables, max_per_run)
+
     # Drain the backlog one bounded, id-ordered window at a time. Each
     # window is its own retried SERIALIZABLE pass, so memory and
     # lock/transaction-hold time stay bounded per pass even on the first
@@ -513,13 +663,24 @@ def _prune_old_versions_impl(retention_days: int) -> 
dict[str, Any]:
         "pruned_child_shadows": 0,
         "pruned_m2m_shadows": 0,
     }
-    total_retried = 0
-    after_id = 0
+    total_retried: int = 0
+    after_id: int = 0
+    pass_stats: dict[str, Any]
+    retries: int
     while True:
-        pass_stats, retries = _run_pass_with_retry(cutoff, tables, after_id)
+        remaining_budget: int | None = (
+            max_per_run - totals["pruned_transactions"]
+            if max_per_run is not None
+            else None
+        )
+        pass_stats, retries = _run_pass_with_retry(
+            cutoff, tables, after_id, max_prune=remaining_budget
+        )
         total_retried += retries
         for key in totals:
             totals[key] += pass_stats.get(key, 0)
+        if max_per_run is not None and totals["pruned_transactions"] >= 
max_per_run:
+            break
         if pass_stats.get("candidate_count", 0) < _MAX_PRUNE_BATCH:
             break
         after_id = pass_stats.get("max_candidate_id", after_id)
@@ -527,6 +688,7 @@ def _prune_old_versions_impl(retention_days: int) -> 
dict[str, Any]:
     stats: dict[str, Any] = {"cutoff": cutoff.isoformat(), **totals}
     if total_retried:
         stats["retried"] = total_retried
+    _add_prune_cap_stats(stats, cutoff, tables, max_per_run, after_id)
     stats_logger_manager.instance.gauge(
         f"{_METRIC_PREFIX}.pruned_transactions", stats["pruned_transactions"]
     )
@@ -556,7 +718,27 @@ def prune_old_versions() -> dict[str, Any]:
         retention_days: int = _resolve_version_history_retention_days(
             configured, legacy_configured, seed=_version_history_retention_seed
         )
-        return _prune_old_versions_impl(retention_days)
+        try:
+            max_per_run: int | None = validate_retention_cap(
+                current_app.config.get(
+                    "VERSION_HISTORY_PRUNE_MAX_TRANSACTIONS_PER_RUN", 1000
+                ),
+                "VERSION_HISTORY_PRUNE_MAX_TRANSACTIONS_PER_RUN",
+            )
+        except ValueError:
+            logger.warning("version_history_retention: invalid prune cap; 
skipping")
+            
stats_logger_manager.instance.incr(f"{_METRIC_PREFIX}.skipped_invalid_cap")
+            return {"skipped_invalid_cap": 1}
+        dry_run: object = 
current_app.config.get("VERSION_HISTORY_PRUNE_DRY_RUN", False)
+        if not isinstance(dry_run, bool):
+            logger.warning("version_history_retention: invalid prune dry-run; 
skipping")
+            stats_logger_manager.instance.incr(
+                f"{_METRIC_PREFIX}.skipped_invalid_dry_run"
+            )
+            return {"skipped_invalid_dry_run": 1}
+        return _prune_old_versions_impl(
+            retention_days, max_per_run=max_per_run, dry_run=dry_run
+        )
     except Exception:  # pylint: disable=broad-except
         logger.exception("version_history.prune_old_versions: task failed")
         # Emit a failure counter so a prune that fails every night (e.g. an
diff --git a/tests/integration_tests/versioning/retention_prune_tests.py 
b/tests/integration_tests/versioning/retention_prune_tests.py
index d870ebf8a4f..5a29a5f7f80 100644
--- a/tests/integration_tests/versioning/retention_prune_tests.py
+++ b/tests/integration_tests/versioning/retention_prune_tests.py
@@ -194,6 +194,71 @@ class TestDashboardVersionRetention(SupersetTestCase):
             dashboard.dashboard_title = original_title
             db.session.commit()
 
+    def test_cap_preserves_whole_transactions_across_runs(self) -> None:
+        """Two capped passes each remove two old transactions, never live 
rows."""
+        from datetime import datetime, timedelta, timezone
+
+        import sqlalchemy as sa
+        from sqlalchemy_continuum import versioning_manager
+
+        from superset.tasks.version_history_retention import 
_prune_old_versions_impl
+
+        _persist_fixture_state()
+        dashboard: Dashboard = (
+            db.session.query(Dashboard)
+            .filter(Dashboard.dashboard_title == "USA Births Names")
+            .first()
+        )
+        assert dashboard is not None
+        original_title: str = dashboard.dashboard_title
+        try:
+            index: int
+            for index in range(4):
+                dashboard.dashboard_title = f"USA Births Names capped {index}"
+                db.session.commit()
+
+            tx_table: sa.Table = versioning_manager.transaction_cls.__table__
+            with db.engine.begin() as conn:
+                conn.execute(
+                    sa.update(tx_table).values(
+                        
issued_at=datetime.now(timezone.utc).replace(tzinfo=None)
+                        - timedelta(days=100)
+                    )
+                )
+
+            count_before: int = db.session.scalar(
+                sa.select(sa.func.count()).select_from(tx_table)
+            )
+            preview: dict[str, Any] = _prune_old_versions_impl(
+                30, max_per_run=2, dry_run=True
+            )
+            assert preview["eligible_backlog"] >= 4
+            assert preview["estimated_capped_runs"] >= 2
+            assert (
+                
db.session.scalar(sa.select(sa.func.count()).select_from(tx_table))
+                == count_before
+            )
+
+            first: dict[str, Any] = _prune_old_versions_impl(30, max_per_run=2)
+            second: dict[str, Any] = _prune_old_versions_impl(30, 
max_per_run=2)
+            assert first["pruned_transactions"] == 2
+            assert second["pruned_transactions"] == 2
+            assert first["cap_reached"] is True
+            assert second["cap_reached"] is True
+            assert first["remaining_eligible"] == preview["eligible_backlog"] 
- 2
+            assert second["remaining_eligible"] == preview["eligible_backlog"] 
- 4
+            assert (
+                
db.session.scalar(sa.select(sa.func.count()).select_from(tx_table))
+                == count_before - 4
+            )
+            db.session.expire_all()
+            assert any(
+                row.end_transaction_id is None for row in 
_get_version_rows(dashboard)
+            )
+        finally:
+            dashboard.dashboard_title = original_title
+            db.session.commit()
+
     def test_retention_preserves_live_child_and_m2m_rows(self) -> None:
         """Regression for the live-row preservation BLOCKER.
 
diff --git 
a/tests/unit_tests/commands/deletion_retention/test_purge_chart_detach.py 
b/tests/unit_tests/commands/deletion_retention/test_purge_chart_detach.py
index 5f1082c46eb..ca80bf36d0c 100644
--- a/tests/unit_tests/commands/deletion_retention/test_purge_chart_detach.py
+++ b/tests/unit_tests/commands/deletion_retention/test_purge_chart_detach.py
@@ -280,24 +280,22 @@ def test_failed_purge_rolls_back_chart_detach(
 
 def test_dry_run_purge_changes_no_chart(session: Session, app_context: None) 
-> None:
     """A dry run counts eligible datasets and never touches their charts."""
-    from superset.tasks.deletion_retention import _purge_model
+    from superset.tasks.deletion_retention import _purge_model, 
_PurgeModelResult
 
     dataset: SqlaTable
     orphan: Slice
     dataset, orphan, _, _ = _trashed_dataset_with_charts(session)
     before: tuple[Any, ...] = (orphan.datasource_id, orphan.perm, 
orphan.schema_perm)
 
-    purged: int
-    would_purge: int
-    failures: int
-    blocked: int
-    scan_failures: int
-    purged, would_purge, failures, blocked, scan_failures = _purge_model(
-        SqlaTable, datetime(2021, 1, 1), True
-    )
+    result: _PurgeModelResult = _purge_model(SqlaTable, datetime(2021, 1, 1), 
True)
     session.expire_all()
 
-    assert (purged, would_purge, failures, blocked, scan_failures) == (0, 1, 
0, 0, 0)
+    assert result.purged == 0
+    assert result.would_purge == 1
+    assert result.failures == 0
+    assert result.blocked == 0
+    assert result.scan_failures == 0
+    assert result.commit_uncertain is False
     chart: Slice = session.get(Slice, orphan.id)
     assert (chart.datasource_id, chart.perm, chart.schema_perm) == before
     assert session.get(SqlaTable, dataset.id) is not None
diff --git a/tests/unit_tests/tasks/test_deletion_retention.py 
b/tests/unit_tests/tasks/test_deletion_retention.py
index f15a7357dd7..f948594bbce 100644
--- a/tests/unit_tests/tasks/test_deletion_retention.py
+++ b/tests/unit_tests/tasks/test_deletion_retention.py
@@ -24,13 +24,14 @@ import runpy
 from collections.abc import Iterator
 from datetime import datetime, timedelta
 from pathlib import Path
-from typing import Any, ClassVar
+from typing import Any, cast, 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.exc import OperationalError
 from sqlalchemy.orm import Mapped, mapped_column, registry, Session
 
 
@@ -39,6 +40,7 @@ def app_config(app_context: None, monkeypatch: 
pytest.MonkeyPatch) -> Config:
     from flask import current_app
 
     current_app.config["SOFT_DELETE_RETENTION_DAYS"] = 30
+    current_app.config["SOFT_DELETE_PURGE_MAX_PER_RUN"] = 0
     monkeypatch.setitem(current_app.config, "SOFT_DELETE_RETENTION_DAYS_FUNC", 
None)
     return current_app.config
 
@@ -230,13 +232,17 @@ def test_clock_uses_now_not_utcnow() -> None:
     with (
         patch.object(mod, "datetime") as clock,
         patch.object(mod, "_soft_delete_models", return_value=[Slice]),
-        patch.object(mod, "_purge_model", return_value=(0, 0, 0, 0, 0)) as 
purge,
+        patch.object(
+            mod, "_purge_model", return_value=mod._PurgeModelResult(0, 0, 0, 
0, 0)
+        ) as purge,
         patch.object(mod.audit, "reconcile_pending"),
     ):
         clock.now.return_value = now
         mod._purge_impl(30, dry_run=False)
 
-    purge.assert_called_once_with(Slice, now - timedelta(days=30), False)
+    purge.assert_called_once_with(
+        Slice, now - timedelta(days=30), False, max_per_run=None
+    )
 
 
 @pytest.mark.parametrize("dry_run", [False, True])
@@ -354,12 +360,17 @@ def 
test_scan_failure_keeps_the_counts_earned_before_it(app_context: None) -> No
         patch.object(mod, "_iter_eligible_ids", side_effect=pages),
         patch.object(mod, "_purge_one", return_value=purged_result),
     ):
-        purged, would, failures, blocked, scan_failures = mod._purge_model(
+        counts: mod._PurgeModelResult = mod._purge_model(
             Slice, datetime.now(), dry_run=False
         )
 
-    assert (purged, would, failures, blocked) == (2, 0, 0, 0)
-    assert scan_failures == 1
+    assert (counts.purged, counts.would_purge, counts.failures, 
counts.blocked) == (
+        2,
+        0,
+        0,
+        0,
+    )
+    assert counts.scan_failures == 1
 
 
 def test_every_root_failing_reports_the_pass_as_failed(
@@ -376,8 +387,8 @@ def test_every_root_failing_reports_the_pass_as_failed(
 
     monkeypatch.setitem(app_config, "SOFT_DELETE_PURGE_DRY_RUN", False)
 
-    def every_scan_fails(*args: Any, **kwargs: Any) -> tuple[int, int, int, 
int, int]:
-        return (0, 0, 0, 0, 1)
+    def every_scan_fails(*args: Any, **kwargs: Any) -> task._PurgeModelResult:
+        return task._PurgeModelResult(0, 0, 0, 0, 1)
 
     counter: MagicMock
     with (
@@ -429,7 +440,9 @@ def test_root_without_a_table_name_does_not_abort_the_run(
 
     counter: MagicMock
     with (
-        patch.object(task, "_purge_model", return_value=(1, 0, 0, 0, 0)),
+        patch.object(
+            task, "_purge_model", return_value=task._PurgeModelResult(1, 0, 0, 
0, 0)
+        ),
         patch.object(task.audit, "reconcile_pending"),
         patch.object(task, "resolve_retention_window", return_value=30),
         patch.object(
@@ -483,7 +496,11 @@ def 
test_unsupported_model_does_not_prevent_supported_models_from_purging(
         patch.object(
             task,
             "_purge_model",
-            return_value=(0, 1, 0, 0, 0) if dry_run else (1, 0, 0, 0, 0),
+            return_value=(
+                task._PurgeModelResult(0, 1, 0, 0, 0)
+                if dry_run
+                else task._PurgeModelResult(1, 0, 0, 0, 0)
+            ),
         ) as purge,
         patch.object(task.audit, "reconcile_pending"),
         patch.object(task, "resolve_retention_window", return_value=30),
@@ -498,7 +515,7 @@ def 
test_unsupported_model_does_not_prevent_supported_models_from_purging(
     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 call(model, ANY, dry_run, max_per_run=None) in 
purge.call_args_list
     assert result["would_purge" if dry_run else "purged"] == {
         "dashboards": 1,
         "slices": 1,
@@ -601,11 +618,396 @@ def 
test_purge_model_counts_only_committed_deletions(app_context: None) -> None:
         patch.object(mod, "_iter_eligible_ids", return_value=[[1]]),
         patch.object(mod, "_purge_one", return_value=lost_race),
     ):
-        result: tuple[int, int, int, int, int] = mod._purge_model(
+        result: mod._PurgeModelResult = mod._purge_model(
             Slice, datetime.now(), dry_run=False
         )
 
-    assert result == (0, 0, 0, 0, 0)
+    assert result == mod._PurgeModelResult(0, 0, 0, 0, 0)
+
+
+def test_purge_cap_skips_blocked_and_failed_roots(app_context: None) -> None:
+    """Blocked and failed attempts leave budget for two successful roots."""
+    import superset.tasks.deletion_retention as task
+    from superset.commands.deletion_retention.purge_cascade import 
CascadeResult
+    from superset.commands.deletion_retention.purge_policy import BlockerReason
+    from superset.models.slice import Slice
+
+    blocked: CascadeResult = CascadeResult(
+        purged=False,
+        entity_type="chart",
+        entity_uuid="blocked",
+        blocker=BlockerReason("referenced", "still referenced"),
+    )
+    confirmed: CascadeResult = CascadeResult(
+        purged=True, entity_type="chart", entity_uuid="confirmed"
+    )
+    purge_one: MagicMock
+    with (
+        patch.object(task, "_iter_eligible_ids", return_value=[[1, 2, 3, 4]]),
+        patch.object(
+            task,
+            "_purge_one",
+            side_effect=[blocked, RuntimeError("cascade failed"), confirmed, 
confirmed],
+        ) as purge_one,
+    ):
+        counts: task._PurgeModelResult = task._purge_model(
+            Slice, datetime.now(), dry_run=False, max_per_run=2
+        )
+
+    assert (
+        counts.purged,
+        counts.would_purge,
+        counts.failures,
+        counts.blocked,
+    ) == (2, 0, 1, 1)
+    assert [entry.args[1] for entry in purge_one.call_args_list] == [1, 2, 3, 
4]
+
+
+def test_purge_model_returns_named_counts(app_context: None) -> None:
+    """Callers can read each purge count without depending on tuple order."""
+    import superset.tasks.deletion_retention as task
+    from superset.models.slice import Slice
+
+    with patch.object(task, "_iter_eligible_ids", return_value=[]):
+        counts: task._PurgeModelResult = task._purge_model(
+            Slice, datetime.now(), dry_run=False
+        )
+
+    assert counts.purged == 0
+    assert counts.would_purge == 0
+    assert counts.failures == 0
+    assert counts.blocked == 0
+    assert counts.scan_failures == 0
+
+
[email protected](
+    "commit_error",
+    [
+        RuntimeError("acknowledgement lost"),
+        OperationalError(
+            "COMMIT", {}, Exception("connection lost"), 
connection_invalidated=True
+        ),
+    ],
+)
+def test_uncertain_purge_commit_preserves_pending_audit(
+    app_context: None, commit_error: Exception
+) -> None:
+    """A lost commit acknowledgement cannot be recorded as a failed purge."""
+    import superset.tasks.deletion_retention as task
+    from superset.commands.deletion_retention.purge_cascade import 
CascadeResult
+    from superset.models.slice import Slice
+
+    entity: MagicMock = MagicMock(id=1)
+    result: CascadeResult = CascadeResult(
+        purged=True, entity_type="chart", entity_uuid="uncertain"
+    )
+    audit_fail: MagicMock
+    audit_confirm: MagicMock
+    with (
+        patch.object(task, "skip_visibility_filter"),
+        patch.object(task, "entity_uuid", return_value="uuid-1"),
+        patch.object(task, "dashboard_slice_count", return_value=0),
+        patch.object(task, "suppress_purge_association_versions"),
+        patch.object(task, "cascade_hard_delete", return_value=result),
+        patch.object(task.db, "session") as session,
+        patch.object(task.audit, "write_ahead", return_value=uuid4()),
+        patch.object(task.audit, "fail") as audit_fail,
+        patch.object(task.audit, "confirm") as audit_confirm,
+    ):
+        session.get.return_value = entity
+        session.commit.side_effect = commit_error
+        with pytest.raises(RuntimeError, match="commit outcome"):
+            task._purge_one(Slice, 1, datetime.now())
+
+    audit_fail.assert_not_called()
+    audit_confirm.assert_not_called()
+
+
+def test_failed_commit_rollback_stops_remaining_purge_roots(
+    app_context: None,
+) -> None:
+    """An unconfirmed rollback leaves audit pending and stops later roots."""
+    import superset.tasks.deletion_retention as task
+    from superset.commands.deletion_retention.purge_cascade import 
CascadeResult
+    from superset.models.slice import Slice
+
+    entity: MagicMock = MagicMock(id=1)
+    cascade_result: CascadeResult = CascadeResult(
+        purged=True, entity_type="chart", entity_uuid="uncertain"
+    )
+    audit_fail: MagicMock
+    audit_confirm: MagicMock
+    with (
+        patch.object(task, "_iter_eligible_ids", return_value=[[1, 2]]),
+        patch.object(task, "skip_visibility_filter"),
+        patch.object(task, "entity_uuid", return_value="uuid-1"),
+        patch.object(task, "dashboard_slice_count", return_value=0),
+        patch.object(task, "suppress_purge_association_versions"),
+        patch.object(task, "cascade_hard_delete", return_value=cascade_result),
+        patch.object(task.db, "session") as session,
+        patch.object(task.audit, "write_ahead", return_value=uuid4()),
+        patch.object(task.audit, "fail") as audit_fail,
+        patch.object(task.audit, "confirm") as audit_confirm,
+    ):
+        session.get.return_value = entity
+        session.commit.side_effect = OperationalError(
+            "COMMIT", {}, Exception("transaction rejected")
+        )
+        session.rollback.side_effect = [None, RuntimeError("rollback failed")]
+        result: task._PurgeModelResult = task._purge_model(
+            Slice, datetime.now(), dry_run=False, max_per_run=2
+        )
+
+    assert result.commit_uncertain is True
+    assert result.failures == 0
+    assert result.purged == 0
+    assert session.get.call_count == 2
+    assert session.commit.call_count == 1
+    assert session.rollback.call_count == 2
+    audit_fail.assert_not_called()
+    audit_confirm.assert_not_called()
+
+
[email protected]("failure_stage", ["flush", "commit"])
+def test_definitive_purge_db_failure_finalizes_audit(
+    app_context: None, failure_stage: str
+) -> None:
+    """An acknowledged database rejection is a failed root, not uncertainty."""
+    import superset.tasks.deletion_retention as task
+    from superset.commands.deletion_retention.purge_cascade import 
CascadeResult
+    from superset.models.slice import Slice
+
+    entity: MagicMock = MagicMock(id=1)
+    result: CascadeResult = CascadeResult(
+        purged=True, entity_type="chart", entity_uuid="rejected"
+    )
+    db_error: OperationalError = OperationalError(
+        failure_stage, {}, Exception("transaction rejected")
+    )
+    audit_fail: MagicMock
+    with (
+        patch.object(task, "skip_visibility_filter"),
+        patch.object(task, "entity_uuid", return_value="uuid-1"),
+        patch.object(task, "dashboard_slice_count", return_value=0),
+        patch.object(task, "suppress_purge_association_versions"),
+        patch.object(task, "cascade_hard_delete", return_value=result),
+        patch.object(task.db, "session") as session,
+        patch.object(task.audit, "write_ahead", return_value=uuid4()),
+        patch.object(task.audit, "fail") as audit_fail,
+    ):
+        session.get.return_value = entity
+        getattr(session, failure_stage).side_effect = db_error
+        with pytest.raises(OperationalError):
+            task._purge_one(Slice, 1, datetime.now())
+
+    audit_fail.assert_called_once()
+    session.rollback.assert_called()
+    if failure_stage == "flush":
+        session.commit.assert_not_called()
+
+
+def test_uncertain_purge_commit_stops_other_roots_and_reserves_cap(
+    app_context: None,
+) -> None:
+    """A possible committed root spends budget and defers the invocation."""
+    import superset.tasks.deletion_retention as task
+    from superset.commands.deletion_retention.purge_cascade import 
CascadeResult
+    from superset.models.dashboard import Dashboard
+    from superset.models.slice import Slice
+
+    confirmed: CascadeResult = CascadeResult(
+        purged=True, entity_type="chart", entity_uuid="confirmed"
+    )
+    purge_one: MagicMock
+    count_eligible: MagicMock
+    with (
+        patch.object(task, "_ordered_purge_models", return_value=[Slice, 
Dashboard]),
+        patch.object(task, "_iter_eligible_ids", return_value=[[1, 2]]),
+        patch.object(
+            task,
+            "_purge_one",
+            side_effect=[
+                confirmed,
+                task._PurgeCommitUncertainError("commit outcome unknown"),
+            ],
+        ) as purge_one,
+        patch.object(task, "_count_eligible") as count_eligible,
+        patch.object(task.audit, "reconcile_pending"),
+    ):
+        stats: dict[str, Any] = task._purge_impl(30, False, max_per_run=2)
+
+    assert stats["purged"] == {"slices": 1}
+    assert stats["commit_uncertain"] is True
+    assert stats["cap_reached"] is True
+    assert stats["remaining_eligible"] is None
+    assert stats["remaining_count_complete"] is False
+    assert [entry.args[1] for entry in purge_one.call_args_list] == [1, 2]
+    count_eligible.assert_not_called()
+
+
+def test_uncertain_purge_commit_stops_remaining_ids_in_batch(
+    app_context: None,
+) -> None:
+    """A possible committed root stops a model before the next eligible ID."""
+    import superset.tasks.deletion_retention as task
+    from superset.commands.deletion_retention.purge_cascade import 
CascadeResult
+    from superset.models.slice import Slice
+
+    confirmed: CascadeResult = CascadeResult(
+        purged=True, entity_type="chart", entity_uuid="confirmed"
+    )
+    purge_one: MagicMock
+    with (
+        patch.object(task, "_iter_eligible_ids", return_value=[[1, 2, 3]]),
+        patch.object(
+            task,
+            "_purge_one",
+            side_effect=[
+                confirmed,
+                task._PurgeCommitUncertainError("commit outcome unknown"),
+                confirmed,
+            ],
+        ) as purge_one,
+    ):
+        counts: task._PurgeModelResult = task._purge_model(
+            Slice, datetime.now(), dry_run=False, max_per_run=2
+        )
+
+    assert counts.purged == 1
+    assert counts.commit_uncertain is True
+    assert [entry.args[1] for entry in purge_one.call_args_list] == [1, 2]
+
+
+def test_uncertain_purge_commit_stops_next_batch_with_budget_remaining(
+    app_context: None,
+) -> None:
+    """An uncertain commit must not spend the unused cap on a later page."""
+    import superset.tasks.deletion_retention as task
+    from superset.commands.deletion_retention.purge_cascade import 
CascadeResult
+    from superset.models.slice import Slice
+
+    confirmed: CascadeResult = CascadeResult(
+        purged=True, entity_type="chart", entity_uuid="confirmed"
+    )
+    purge_one: MagicMock
+    with (
+        patch.object(task, "_iter_eligible_ids", return_value=[[1, 2], [3]]),
+        patch.object(
+            task,
+            "_purge_one",
+            side_effect=[
+                confirmed,
+                task._PurgeCommitUncertainError("commit outcome unknown"),
+                confirmed,
+            ],
+        ) as purge_one,
+    ):
+        counts: task._PurgeModelResult = task._purge_model(
+            Slice, datetime.now(), dry_run=False, max_per_run=3
+        )
+
+    assert counts.purged == 1
+    assert counts.commit_uncertain is True
+    assert [entry.args[1] for entry in purge_one.call_args_list] == [1, 2]
+
+
+def test_uncertain_purge_commit_stops_next_model_with_budget_remaining(
+    app_context: None,
+) -> None:
+    """An unknown outcome halts the run even when the cap is not exhausted."""
+    import superset.tasks.deletion_retention as task
+    from superset.models.dashboard import Dashboard
+    from superset.models.slice import Slice
+
+    purge_model: MagicMock
+    with (
+        patch.object(task, "_ordered_purge_models", return_value=[Slice, 
Dashboard]),
+        patch.object(
+            task,
+            "_purge_model",
+            side_effect=[
+                task._PurgeModelResult(
+                    purged=0,
+                    would_purge=0,
+                    failures=0,
+                    blocked=0,
+                    scan_failures=0,
+                    commit_uncertain=True,
+                ),
+                task._PurgeModelResult(
+                    purged=1, would_purge=0, failures=0, blocked=0, 
scan_failures=0
+                ),
+            ],
+        ) as purge_model,
+    ):
+        scan: task._PurgeScan = task._scan_purge_models(
+            datetime.now(), dry_run=False, max_per_run=5
+        )
+
+    assert scan.commit_uncertain is True
+    assert scan.remaining_budget == 4
+    assert purge_model.call_count == 1
+
+
+def test_post_commit_audit_exception_still_spends_cap(app_context: None) -> 
None:
+    """A committed deletion retains its cap slot if audit finalization 
raises."""
+    import superset.tasks.deletion_retention as task
+    from superset.commands.deletion_retention.purge_cascade import 
CascadeResult
+    from superset.models.slice import Slice
+
+    entity: MagicMock = MagicMock(id=1)
+    result: CascadeResult = CascadeResult(
+        purged=True, entity_type="chart", entity_uuid="committed"
+    )
+    cascade: MagicMock
+    audit_fail: MagicMock
+    with (
+        patch.object(task, "_iter_eligible_ids", return_value=[[1, 2]]),
+        patch.object(task, "skip_visibility_filter"),
+        patch.object(task, "entity_uuid", return_value="uuid-1"),
+        patch.object(task, "dashboard_slice_count", return_value=0),
+        patch.object(task, "suppress_purge_association_versions"),
+        patch.object(task, "cascade_hard_delete", return_value=result) as 
cascade,
+        patch.object(task.db, "session") as session,
+        patch.object(task.audit, "write_ahead", return_value=uuid4()),
+        patch.object(task.audit, "confirm", side_effect=RuntimeError("audit 
offline")),
+        patch.object(task.audit, "fail") as audit_fail,
+    ):
+        session.get.return_value = entity
+        counts: task._PurgeModelResult = task._purge_model(
+            Slice, datetime.now(), dry_run=False, max_per_run=1
+        )
+
+    assert counts.purged == 1
+    assert counts.failures == 0
+    assert cascade.call_count == 1
+    session.commit.assert_called_once()
+    audit_fail.assert_not_called()
+
+
+def test_dry_run_backlog_is_incomplete_after_scan_failure(app_context: None) 
-> None:
+    """A partial model scan cannot yield a definitive dry-run estimate."""
+    import superset.tasks.deletion_retention as task
+    from superset.models.slice import Slice
+
+    scan: task._PurgeScan = task._PurgeScan(
+        purged={},
+        would_purge={"slices": 2},
+        unsupported_models={},
+        failures=0,
+        blocked=0,
+        remaining_budget=3,
+        supported_models=[Slice],
+        scan_failures=1,
+        attempted=1,
+    )
+    with patch.object(task, "_scan_purge_models", return_value=scan):
+        stats: dict[str, Any] = task._purge_impl(30, True, max_per_run=3)
+
+    assert stats["would_purge"] == {"slices": 2}
+    assert stats["eligible_backlog"] is None
+    assert stats["estimated_capped_runs"] is None
+    assert stats["backlog_count_complete"] is False
 
 
 def test_scheduled_purge_fails_closed_when_write_ahead_fails(
@@ -627,14 +1029,13 @@ def 
test_scheduled_purge_fails_closed_when_write_ahead_fails(
         patch.object(mod.audit, "write_ahead", return_value=None),
     ):
         session.get.return_value = entity
-        result: tuple[int, int, int, int, int] = mod._purge_model(
+        result: mod._PurgeModelResult = mod._purge_model(
             Slice, datetime.now(), dry_run=False
         )
 
     cascade.assert_not_called()
-    purged, would, failures, blocked, _ = result
-    assert (purged, would, blocked) == (0, 0, 0)
-    assert failures == 1
+    assert (result.purged, result.would_purge, result.blocked) == (0, 0, 0)
+    assert result.failures == 1
 
 
 @pytest.mark.parametrize("days", [-1, -2])
@@ -651,13 +1052,15 @@ def test_immediate_cutoff_and_invalid_skip(days: int) -> 
None:
     with (
         patch.object(mod, "datetime") as clock,
         patch.object(mod, "_soft_delete_models", return_value=[Slice]) as 
models,
-        patch.object(mod, "_purge_model", return_value=(0, 0, 0, 0, 0)) as 
purge,
+        patch.object(
+            mod, "_purge_model", return_value=mod._PurgeModelResult(0, 0, 0, 
0, 0)
+        ) as purge,
         patch.object(mod.audit, "reconcile_pending") as reconcile,
     ):
         clock.now.return_value = now
         result: dict[str, Any] = mod._purge_impl(days, dry_run=False)
     if days == -1:
-        purge.assert_called_once_with(Slice, now, False)
+        purge.assert_called_once_with(Slice, now, False, max_per_run=None)
     else:
         assert result == {"skipped": 1}
         models.assert_not_called()
@@ -685,7 +1088,9 @@ def test_standalone_window_bounds_reach_safe_purge_cutoff(
         ),
         patch.object(mod, "datetime") as clock,
         patch.object(mod, "_soft_delete_models", return_value=[Slice]),
-        patch.object(mod, "_purge_model", return_value=(0, 0, 0, 0, 0)) as 
purge,
+        patch.object(
+            mod, "_purge_model", return_value=mod._PurgeModelResult(0, 0, 0, 
0, 0)
+        ) as purge,
         patch.object(mod.audit, "reconcile_pending"),
     ):
         clock.now.return_value = now
@@ -694,4 +1099,353 @@ def 
test_standalone_window_bounds_reach_safe_purge_cutoff(
         assert result == {"skipped": 1}
         purge.assert_not_called()
     else:
-        purge.assert_called_once_with(Slice, now - timedelta(days=expected), 
False)
+        purge.assert_called_once_with(
+            Slice, now - timedelta(days=expected), False, max_per_run=None
+        )
+
+
+def test_count_eligible_counts_only_roots_archived_before_cutoff(
+    session: Session, app_context: None
+) -> None:
+    """The backlog count runs its real predicate and agrees with the purge 
scan."""
+    from superset.models.slice import Slice
+    from superset.tasks import deletion_retention as task
+
+    table: sa.Table = Slice.__table__
+    table.create(session.get_bind())
+    cutoff: datetime = datetime(2026, 1, 31)
+    # Core inserts keep Slice's ORM listeners out of a predicate-only test.
+    session.execute(
+        sa.insert(table),
+        [
+            {"id": 1, "deleted_at": cutoff - timedelta(days=1)},
+            {"id": 2, "deleted_at": cutoff - timedelta(seconds=1)},
+            {"id": 3, "deleted_at": cutoff},
+            {"id": 4, "deleted_at": cutoff + timedelta(days=1)},
+            {"id": 5, "deleted_at": None},
+        ],
+    )
+    session.commit()
+
+    assert task._count_eligible(Slice, cutoff) == 2
+    assert list(task._iter_eligible_ids(Slice, cutoff, batch=10)) == [[1, 2]]
+
+
+def test_purge_cap_counts_committed_roots_across_batches(app_context: None) -> 
None:
+    """A failed or blocked root does not consume the successful-purge 
budget."""
+    from superset.commands.deletion_retention.purge_cascade import 
CascadeResult
+    from superset.models.slice import Slice
+    from superset.tasks import deletion_retention as task
+
+    purged: CascadeResult = CascadeResult(
+        purged=True, entity_type="chart", entity_uuid="purged"
+    )
+    not_purged: CascadeResult = CascadeResult(
+        purged=False, entity_type="chart", entity_uuid="not-purged"
+    )
+    purge_one: MagicMock
+    with (
+        patch.object(task, "_iter_eligible_ids", return_value=[[1, 2], [3, 
4]]) as scan,
+        patch.object(
+            task, "_purge_one", side_effect=[not_purged, purged, purged]
+        ) as purge_one,
+    ):
+        result: task._PurgeModelResult = task._purge_model(
+            Slice, datetime.now(), dry_run=False, max_per_run=2
+        )
+
+    assert result == task._PurgeModelResult(2, 0, 0, 0, 0)
+    assert [call.args[1] for call in purge_one.call_args_list] == [1, 2, 3]
+    scan.assert_called_once()
+
+
+def test_scheduled_purge_forwards_positive_cap(app_config: Config) -> None:
+    """The scheduled entrypoint preserves the operator's positive purge 
budget."""
+    from superset.tasks import deletion_retention as task
+
+    app_config["SOFT_DELETE_PURGE_MAX_PER_RUN"] = 2
+    app_config["SOFT_DELETE_RETENTION_DAYS"] = 30
+    app_config["SOFT_DELETE_PURGE_DRY_RUN"] = False
+    purge: MagicMock
+    with (
+        patch.object(task, "_purge_impl", return_value={"purged": 2}) as purge,
+        patch.object(task, "resolve_retention_window", return_value=30),
+        patch.object(
+            task.feature_flag_manager, "is_feature_enabled", return_value=True
+        ),
+    ):
+        result: dict[str, Any] = task.purge_soft_deleted.run()
+
+    purge.assert_called_once_with(30, False, max_per_run=2)
+    assert result == {"purged": 2}
+
+
+def test_scheduled_purge_rejects_invalid_cap_before_work(app_config: Config) 
-> None:
+    """Malformed scheduled budgets fail closed instead of removing 
everything."""
+    from superset.tasks import deletion_retention as task
+
+    app_config["SOFT_DELETE_PURGE_MAX_PER_RUN"] = -1
+    purge: MagicMock
+    with (
+        patch.object(task, "_purge_impl") as purge,
+        patch.object(
+            task.feature_flag_manager, "is_feature_enabled", return_value=True
+        ),
+    ):
+        result: dict[str, Any] = task.purge_soft_deleted.run()
+
+    assert result == {"skipped_invalid_cap": 1}
+    purge.assert_not_called()
+
+
+def test_purge_budget_spans_models_and_runs(app_context: None) -> None:
+    """A full first run leaves later models for a subsequent invocation."""
+    from superset.models.dashboard import Dashboard
+    from superset.models.slice import Slice
+    from superset.tasks import deletion_retention as task
+
+    purge: MagicMock
+    with (
+        patch.object(task, "_ordered_purge_models", return_value=[Slice, 
Dashboard]),
+        patch.object(
+            task,
+            "_purge_model",
+            side_effect=[
+                task._PurgeModelResult(2, 0, 0, 0, 0),
+                task._PurgeModelResult(0, 0, 0, 0, 0),
+                task._PurgeModelResult(1, 0, 0, 0, 0),
+            ],
+        ) as purge,
+        patch.object(task, "_count_eligible", side_effect=[0, 3, 0, 2]),
+        patch.object(task.audit, "reconcile_pending"),
+    ):
+        first: dict[str, Any] = task._purge_impl(30, False, max_per_run=2)
+        second: dict[str, Any] = task._purge_impl(30, False, max_per_run=2)
+
+    assert first["purged"] == {"slices": 2}
+    assert first["cap_reached"] is True
+    assert first["remaining_eligible"] == 3
+    assert second["purged"] == {"dashboards": 1}
+    assert second["cap_reached"] is False
+    assert second["remaining_eligible"] == 2
+    assert [entry.args[0] for entry in purge.call_args_list] == [
+        Slice,
+        Slice,
+        Dashboard,
+    ]
+    assert [entry.kwargs["max_per_run"] for entry in purge.call_args_list] == [
+        2,
+        2,
+        2,
+    ]
+
+
+def test_purge_budget_remaining_after_first_model(app_context: None) -> None:
+    """A partially used cap limits the next model's eligible roots."""
+    from superset.commands.deletion_retention.purge_cascade import 
CascadeResult
+    from superset.models.dashboard import Dashboard
+    from superset.models.slice import Slice
+    from superset.tasks import deletion_retention as task
+
+    cutoff: datetime = datetime(2026, 10, 1)
+    scan: task._PurgeScan
+    purge_one: MagicMock
+    with (
+        patch.object(task, "_ordered_purge_models", return_value=[Slice, 
Dashboard]),
+        patch.object(
+            task, "_iter_eligible_ids", side_effect=[iter([[1]]), iter([[2, 
3]])]
+        ),
+        patch.object(
+            task,
+            "_purge_one",
+            return_value=CascadeResult(
+                purged=True, entity_type="chart", entity_uuid="x"
+            ),
+        ) as purge_one,
+    ):
+        scan = task._scan_purge_models(cutoff, dry_run=False, max_per_run=2)
+
+    assert scan.purged == {"slices": 1, "dashboards": 1}
+    assert scan.remaining_budget == 0
+    assert [entry.args[:2] for entry in purge_one.call_args_list] == [
+        (Slice, 1),
+        (Dashboard, 2),
+    ]
+
+
[email protected]("rollback_fails", [False, True])
+def test_partial_scan_failure_preserves_counts_and_shared_budget(
+    app_context: None,
+    rollback_fails: bool,
+) -> None:
+    """A failed scan/rollback cannot give the next model spent purge budget."""
+    from superset.commands.deletion_retention.purge_cascade import 
CascadeResult
+    from superset.models.dashboard import Dashboard
+    from superset.models.slice import Slice
+    from superset.tasks import deletion_retention as task
+
+    def partial_scan() -> Iterator[list[int]]:
+        """Return a committed root's page, then fail fetching the next page."""
+        yield [1]
+        raise RuntimeError("scan failed")
+
+    purge_one: MagicMock
+    rollback: MagicMock
+    with (
+        patch.object(task, "_ordered_purge_models", return_value=[Slice, 
Dashboard]),
+        patch.object(
+            task,
+            "_iter_eligible_ids",
+            side_effect=[partial_scan(), iter([[2, 3]])],
+        ),
+        patch.object(
+            task,
+            "_purge_one",
+            return_value=CascadeResult(
+                purged=True, entity_type="chart", entity_uuid="x"
+            ),
+        ) as purge_one,
+        patch.object(
+            task.db.session,
+            "rollback",
+            side_effect=[RuntimeError("rollback failed"), None]
+            if rollback_fails
+            else None,
+        ) as rollback,
+        patch.object(task.stats_logger_manager.instance, "incr"),
+    ):
+        scan: task._PurgeScan = task._scan_purge_models(
+            datetime(2026, 10, 7), dry_run=False, max_per_run=2
+        )
+
+    assert scan.purged == {"slices": 1, "dashboards": 1}
+    assert scan.scan_failures == 1
+    assert scan.remaining_budget == 0
+    assert [entry.args[:2] for entry in purge_one.call_args_list] == [
+        (Slice, 1),
+        (Dashboard, 2),
+    ]
+    rollback.assert_called_once()
+
+
+def test_purge_model_priority_rotates_across_days() -> None:
+    """A cap smaller than the model count still gives each model first turn."""
+    from superset.connectors.sqla.models import SqlaTable
+    from superset.models.dashboard import Dashboard
+    from superset.models.helpers import SoftDeleteMixin
+    from superset.models.slice import Slice
+    from superset.tasks import deletion_retention as task
+
+    models: list[type[SoftDeleteMixin]] = [Slice, Dashboard, SqlaTable]
+    with patch.object(task, "_soft_delete_models", return_value=models):
+        day: int
+        priorities: set[type[SoftDeleteMixin]] = set()
+        for day in (1, 2, 3):
+            priorities.add(task._ordered_purge_models(datetime(2026, 1, 
day))[0])
+
+    assert priorities == set(models)
+
+
[email protected]("cap", [None, 0, 3])
+def test_purge_dry_run_counts_full_backlog_without_writes(
+    app_context: None, cap: int | None
+) -> None:
+    """Dry-run eligibility is independent of a configured deletion budget."""
+    from superset.models.dashboard import Dashboard
+    from superset.models.slice import Slice
+    from superset.tasks import deletion_retention as task
+
+    purge_one: MagicMock
+    reconcile: MagicMock
+    with (
+        patch.object(task, "_ordered_purge_models", return_value=[Slice, 
Dashboard]),
+        patch.object(
+            task,
+            "_iter_eligible_ids",
+            side_effect=[
+                iter([[1, 2], [3, 4]]),
+                iter([[5, 6, 7]]),
+            ],
+        ),
+        patch.object(task, "_purge_one") as purge_one,
+        patch.object(task.audit, "reconcile_pending") as reconcile,
+    ):
+        result: dict[str, Any] = task._purge_impl(30, True, max_per_run=cap)
+
+    assert result["would_purge"] == {"slices": 4, "dashboards": 3}
+    purge_one.assert_not_called()
+    reconcile.assert_not_called()
+    assert result["eligible_backlog"] == 7
+    assert result["estimated_capped_runs"] == (3 if cap == 3 else 1)
+
+
[email protected]("invalid", [-1, True, "2", 1.5])
+def test_purge_rejects_invalid_cap(invalid: object) -> None:
+    """Invalid budgets cannot silently become an unlimited direct call."""
+    from superset.tasks import deletion_retention as task
+
+    with pytest.raises(ValueError, match="SOFT_DELETE_PURGE_MAX_PER_RUN"):
+        task._purge_impl(30, False, max_per_run=cast(int | None, invalid))
+
+
[email protected]("rollback_fails", [False, True])
+def test_purge_remainder_failure_keeps_committed_totals(
+    app_context: None, rollback_fails: bool
+) -> None:
+    """A post-commit count failure cannot turn successful purges into an 
error."""
+    from superset.models.slice import Slice
+    from superset.tasks import deletion_retention as task
+
+    scan: task._PurgeScan = task._PurgeScan(
+        purged={"slices": 2},
+        would_purge={},
+        unsupported_models={},
+        failures=0,
+        blocked=0,
+        remaining_budget=0,
+        supported_models=[Slice],
+    )
+    with (
+        patch.object(task, "_scan_purge_models", return_value=scan),
+        patch.object(task, "_count_eligible", side_effect=RuntimeError("count 
failed")),
+        patch.object(task.audit, "reconcile_pending"),
+        patch.object(
+            task.db.session,
+            "rollback",
+            side_effect=RuntimeError("rollback failed") if rollback_fails else 
None,
+        ) as rollback,
+    ):
+        result: dict[str, Any] = task._purge_impl(30, False, max_per_run=2)
+
+    assert result["purged"] == {"slices": 2}
+    assert result["cap_reached"] is True
+    assert result["remaining_eligible"] is None
+    assert result["remaining_count_complete"] is False
+    rollback.assert_called_once()
+
+
+def test_purge_remainder_incomplete_after_scan_failure(app_context: None) -> 
None:
+    """An unscanned root cannot be silently omitted from a complete count."""
+    from superset.models.slice import Slice
+    from superset.tasks import deletion_retention as task
+
+    scan: task._PurgeScan = task._PurgeScan(
+        purged={},
+        would_purge={},
+        unsupported_models={},
+        failures=0,
+        blocked=0,
+        remaining_budget=1,
+        supported_models=[Slice],
+        scan_failures=1,
+        attempted=2,
+    )
+    with (
+        patch.object(task, "_scan_purge_models", return_value=scan),
+        patch.object(task, "_count_eligible", return_value=0),
+        patch.object(task.audit, "reconcile_pending"),
+    ):
+        result: dict[str, Any] = task._purge_impl(30, False, max_per_run=1)
+
+    assert result["remaining_eligible"] is None
+    assert result["remaining_count_complete"] is False
diff --git a/tests/unit_tests/tasks/test_version_history_retention.py 
b/tests/unit_tests/tasks/test_version_history_retention.py
index 8b5b18f38fb..f9790a4220e 100644
--- a/tests/unit_tests/tasks/test_version_history_retention.py
+++ b/tests/unit_tests/tasks/test_version_history_retention.py
@@ -30,10 +30,11 @@ from __future__ import annotations
 
 from collections.abc import Iterator
 from datetime import datetime
-from typing import Any
-from unittest.mock import MagicMock, patch
+from typing import Any, cast
+from unittest.mock import call, MagicMock, patch
 
 import pytest
+import sqlalchemy as sa
 from flask import Flask
 from sqlalchemy.exc import OperationalError
 from sqlalchemy_continuum.exc import ClassNotVersioned
@@ -81,7 +82,7 @@ def test_task_reads_canonical_application_retention(
         ) as prune,
     ):
         assert version_history_retention.prune_old_versions() == {}
-    prune.assert_called_once_with(int(value))
+    prune.assert_called_once_with(int(value), max_per_run=1000, dry_run=False)
     stats.incr.assert_not_called()
 
 
@@ -103,7 +104,7 @@ def 
test_task_preserves_legacy_only_custom_application_config(
         ) as prune,
     ):
         assert version_history_retention.prune_old_versions() == {}
-    prune.assert_called_once_with(expected)
+    prune.assert_called_once_with(expected, max_per_run=1000, dry_run=False)
 
 
 @pytest.mark.parametrize(("legacy", "expected"), [(0, 0), (365, 365)])
@@ -124,7 +125,7 @@ def 
test_task_keeps_legacy_when_custom_module_imports_new_default(
         ) as prune,
     ):
         assert version_history_retention.prune_old_versions() == {}
-    prune.assert_called_once_with(expected)
+    prune.assert_called_once_with(expected, max_per_run=1000, dry_run=False)
 
 
 @pytest.mark.parametrize(("canonical", "legacy"), [(7, 365), (365, 0), (-1, 
-1)])
@@ -145,7 +146,7 @@ def 
test_task_honors_explicit_canonical_config_with_legacy_key_present(
         ) as prune,
     ):
         assert version_history_retention.prune_old_versions() == {}
-    prune.assert_called_once_with(canonical)
+    prune.assert_called_once_with(canonical, max_per_run=1000, dry_run=False)
 
 
 @pytest.mark.parametrize("value", [-1.0, -1.5, True, False, None, "abc", 
"30d"])
@@ -163,7 +164,7 @@ def test_task_does_not_coerce_invalid_input_to_immediate(
         ) as prune,
     ):
         assert version_history_retention.prune_old_versions.run() == {}
-    prune.assert_called_once_with(0)
+    prune.assert_called_once_with(0, max_per_run=1000, dry_run=False)
     stats.incr.assert_not_called()
     assert "Invalid VERSION_HISTORY_RETENTION_DAYS" in caplog.text
 
@@ -186,7 +187,7 @@ def test_task_env_canonical_precedes_legacy_key(
         ) as prune,
     ):
         assert version_history_retention.prune_old_versions() == {}
-    prune.assert_called_once_with(30)
+    prune.assert_called_once_with(30, max_per_run=1000, dry_run=False)
 
 
 def test_task_without_either_key_uses_environment_seed(stats: MagicMock) -> 
None:
@@ -201,7 +202,9 @@ def 
test_task_without_either_key_uses_environment_seed(stats: MagicMock) -> None
     ):
         assert version_history_retention.prune_old_versions() == {}
     prune.assert_called_once_with(
-        version_history_retention._version_history_retention_seed
+        version_history_retention._version_history_retention_seed,
+        max_per_run=1000,
+        dry_run=False,
     )
 
 
@@ -223,7 +226,7 @@ def test_task_defers_invalid_legacy_retention_with_zero(
         ) as prune,
     ):
         assert version_history_retention.prune_old_versions() == {}
-    prune.assert_called_once_with(0)
+    prune.assert_called_once_with(0, max_per_run=1000, dry_run=False)
     stats.incr.assert_not_called()
     assert "Invalid SUPERSET_VERSION_HISTORY_RETENTION_DAYS" in caplog.text
 
@@ -243,7 +246,7 @@ def 
test_task_defers_invalid_legacy_only_retention_with_zero(
         ) as prune,
     ):
         assert version_history_retention.prune_old_versions() == {}
-    prune.assert_called_once_with(0)
+    prune.assert_called_once_with(0, max_per_run=1000, dry_run=False)
     stats.incr.assert_not_called()
     assert "Invalid SUPERSET_VERSION_HISTORY_RETENTION_DAYS" in caplog.text
 
@@ -263,7 +266,7 @@ def test_task_normalizes_string_retention_config(stats: 
MagicMock) -> None:
         result = version_history_retention.prune_old_versions()
 
     assert result == {"pruned_transactions": 0}
-    prune.assert_called_once_with(30)
+    prune.assert_called_once_with(30, max_per_run=1000, dry_run=False)
     stats.incr.assert_not_called()
 
 
@@ -407,9 +410,639 @@ def test_immediate_cutoff_and_invalid_skip(stats: 
MagicMock, days: int) -> None:
             days
         )
     if days == -1:
-        run_pass.assert_called_once_with(now, [], 0)
+        run_pass.assert_called_once_with(now, [], 0, max_prune=None)
         assert result["cutoff"] == now.isoformat()
     else:
         assert result == {"skipped": 1}
         tables.assert_not_called()
         run_pass.assert_not_called()
+
+
+def test_prune_cap_accounts_only_for_committed_passes(stats: MagicMock) -> 
None:
+    """A retried pass uses the same allowance and cannot overshoot the cap."""
+    tables: version_history_retention.ShadowTables = (
+        version_history_retention.ShadowTables(
+            parent=[], child=[], m2m=None, transaction=MagicMock()
+        )
+    )
+    first: dict[str, int] = {
+        "candidate_count": 1000,
+        "max_candidate_id": 1000,
+        "pruned_transactions": 2,
+    }
+    second: dict[str, int] = {
+        "candidate_count": 1000,
+        "max_candidate_id": 2000,
+        "pruned_transactions": 1,
+    }
+    run_pass: MagicMock
+    with (
+        patch.object(
+            version_history_retention, "_resolve_shadow_tables", 
return_value=tables
+        ),
+        patch.object(
+            version_history_retention,
+            "_run_pass_with_retry",
+            side_effect=[(first, 1), (second, 0)],
+        ) as run_pass,
+        patch.object(
+            version_history_retention, "_probe_prunable", return_value=(4, 
False)
+        ),
+    ):
+        result: dict[str, Any] = 
version_history_retention._prune_old_versions_impl(
+            retention_days=30, max_per_run=3
+        )
+
+    assert result["pruned_transactions"] == 3
+    assert result["retried"] == 1
+    assert result["cap_reached"] is True
+    assert result["remaining_eligible"] == 4
+    assert result["remaining_count_complete"] is False
+    stats.gauge.assert_any_call(
+        "superset.versioning.retention.remaining_count_complete", 0
+    )
+    assert run_pass.call_args_list[0].kwargs["max_prune"] == 3
+    assert run_pass.call_args_list[1].kwargs["max_prune"] == 1
+
+
+def test_prune_cap_probes_after_preserved_candidate_windows(
+    stats: MagicMock,
+) -> None:
+    """The remainder probe skips windows already scanned before the cap."""
+    tables: version_history_retention.ShadowTables = (
+        version_history_retention.ShadowTables(
+            parent=[], child=[], m2m=None, transaction=MagicMock()
+        )
+    )
+    batch_size: int = version_history_retention._MAX_PRUNE_BATCH
+    windows: dict[int, version_history_retention._PruneWindow] = {
+        0: version_history_retention._PruneWindow([], batch_size, 1000),
+        1000: version_history_retention._PruneWindow([1500], batch_size, 2000),
+    }
+
+    def resolve_window(
+        _conn: sa.engine.Connection,
+        _cutoff: datetime,
+        _shadow_tables: list[sa.Table],
+        after_id: int,
+        _limit: int,
+    ) -> version_history_retention._PruneWindow:
+        """Return the candidate window at the requested scan cursor."""
+        return windows[after_id]
+
+    resolve: MagicMock
+    with (
+        patch.object(
+            version_history_retention, "_resolve_shadow_tables", 
return_value=tables
+        ),
+        patch.object(
+            version_history_retention,
+            "_run_pass_with_retry",
+            side_effect=[
+                ({"candidate_count": batch_size, "max_candidate_id": 1000}, 0),
+                (
+                    {
+                        "candidate_count": batch_size,
+                        "max_candidate_id": 2000,
+                        "pruned_transactions": 1,
+                    },
+                    0,
+                ),
+            ],
+        ),
+        patch.object(version_history_retention, "db"),
+        patch.object(
+            version_history_retention,
+            "_resolve_prune_window",
+            side_effect=resolve_window,
+        ) as resolve,
+    ):
+        result: dict[str, Any] = 
version_history_retention._prune_old_versions_impl(
+            retention_days=30, max_per_run=1
+        )
+
+    assert result["pruned_transactions"] == 1
+    assert result["remaining_eligible"] == 1
+    assert result["remaining_count_complete"] is False
+    assert resolve.call_args.args[3] == 1000
+    stats.gauge.assert_any_call(
+        "superset.versioning.retention.remaining_eligible_at_least", 1
+    )
+
+
[email protected]("cap", [None, 0, 3])
+def test_prune_dry_run_counts_all_eligible_without_writes(
+    stats: MagicMock, cap: int | None
+) -> None:
+    """A dry run reports the whole backlog and capped-run estimate."""
+    tables: version_history_retention.ShadowTables = (
+        version_history_retention.ShadowTables(
+            parent=[], child=[], m2m=None, transaction=MagicMock()
+        )
+    )
+    run_pass: MagicMock
+    with (
+        patch.object(
+            version_history_retention, "_resolve_shadow_tables", 
return_value=tables
+        ),
+        patch.object(version_history_retention, "_count_prunable", 
return_value=7),
+        patch.object(version_history_retention, "_run_pass_with_retry") as 
run_pass,
+    ):
+        result: dict[str, Any] = 
version_history_retention._prune_old_versions_impl(
+            30, max_per_run=cap, dry_run=True
+        )
+
+    assert result["eligible_backlog"] == 7
+    assert result["estimated_capped_runs"] == (3 if cap == 3 else 1)
+    run_pass.assert_not_called()
+    stats.gauge.assert_not_called()
+
+
[email protected]("helper_name", ["_count_prunable", "_probe_prunable"])
+def test_remainder_reads_use_default_isolation(helper_name: str) -> None:
+    """Backlog measurement does not request the delete pass's isolation."""
+    tables: version_history_retention.ShadowTables = (
+        version_history_retention.ShadowTables(
+            parent=[], child=[], m2m=None, transaction=MagicMock()
+        )
+    )
+    engine: MagicMock = MagicMock()
+    window: version_history_retention._PruneWindow = (
+        version_history_retention._PruneWindow(
+            prunable=[], candidate_count=0, max_candidate_id=0
+        )
+    )
+    with (
+        patch.object(version_history_retention, "db") as mock_db,
+        patch.object(
+            version_history_retention, "_resolve_prune_window", 
return_value=window
+        ),
+    ):
+        mock_db.engine = engine
+        result: int | tuple[int, bool] = getattr(
+            version_history_retention, helper_name
+        )(datetime(2026, 1, 1), tables)
+
+    assert result in (0, (0, True))
+    engine.connect.return_value.execution_options.assert_not_called()
+
+
+def test_dry_run_count_accumulates_windows_and_closes_read_transactions() -> 
None:
+    """A paged backlog count sums windows and releases each read snapshot."""
+    tables: version_history_retention.ShadowTables = (
+        version_history_retention.ShadowTables(
+            parent=[], child=[], m2m=None, transaction=MagicMock()
+        )
+    )
+    engine: MagicMock = MagicMock()
+    full_window: version_history_retention._PruneWindow = (
+        version_history_retention._PruneWindow(
+            prunable=[1, 2],
+            candidate_count=version_history_retention._MAX_PRUNE_BATCH,
+            max_candidate_id=1000,
+        )
+    )
+    final_window: version_history_retention._PruneWindow = (
+        version_history_retention._PruneWindow(
+            prunable=[1001], candidate_count=1, max_candidate_id=1001
+        )
+    )
+    mock_db: MagicMock
+    resolve: MagicMock
+    with (
+        patch.object(version_history_retention, "db") as mock_db,
+        patch.object(
+            version_history_retention,
+            "_resolve_prune_window",
+            side_effect=[full_window, final_window],
+        ) as resolve,
+    ):
+        mock_db.engine = engine
+        count: int = version_history_retention._count_prunable(
+            datetime(2026, 1, 1), tables
+        )
+
+    assert count == 3
+    assert resolve.call_args_list[0].args[3] == 0
+    assert resolve.call_args_list[1].args[3] == full_window.max_candidate_id
+    assert engine.connect.call_count == 2
+    assert engine.connect.return_value.__exit__.call_count == 2
+    first_exit: int = engine.mock_calls.index(call.connect().__exit__(None, 
None, None))
+    second_connect: int = engine.mock_calls.index(call.connect(), first_exit + 
1)
+    assert first_exit < second_connect
+
+
[email protected]("invalid", [-1, True, "3", 2.5])
+def test_prune_rejects_invalid_cap_before_work(invalid: object) -> None:
+    """Malformed budgets fail closed before resolving any shadow table."""
+    with (
+        patch.object(version_history_retention, "_resolve_shadow_tables") as 
resolve,
+        pytest.raises(ValueError, match="VERSION_HISTORY_PRUNE_MAX"),
+    ):
+        version_history_retention._prune_old_versions_impl(
+            30, max_per_run=cast(int | None, invalid)
+        )
+    resolve.assert_not_called()
+
+
+def test_prune_retry_reuses_the_same_transaction_budget(stats: MagicMock) -> 
None:
+    """A rolled-back SERIALIZABLE attempt consumes no transaction allowance."""
+    tables: version_history_retention.ShadowTables = (
+        version_history_retention.ShadowTables(
+            parent=[], child=[], m2m=None, transaction=MagicMock()
+        )
+    )
+    cutoff: datetime = datetime(2026, 1, 1)
+    run_pass: MagicMock
+    with (
+        patch.object(
+            version_history_retention,
+            "_run_prune_pass",
+            side_effect=[
+                OperationalError("SELECT 1", {}, Exception("serialization 
conflict")),
+                {"pruned_transactions": 2},
+            ],
+        ) as run_pass,
+        patch.object(version_history_retention.time, "sleep"),
+    ):
+        result: tuple[dict[str, Any], int] = (
+            version_history_retention._run_pass_with_retry(
+                cutoff, tables, after_id=4, max_prune=2
+            )
+        )
+
+    assert result == ({"pruned_transactions": 2}, 1)
+    assert run_pass.call_args_list == [
+        call(cutoff, tables, 4, max_prune=2),
+        call(cutoff, tables, 4, max_prune=2),
+    ]
+    stats.incr.assert_called_once_with("superset.versioning.retention.retried")
+
+
[email protected](
+    "commit_error",
+    [
+        OperationalError(
+            "COMMIT", {}, Exception("connection lost"), 
connection_invalidated=True
+        ),
+        RuntimeError("acknowledgement lost"),
+    ],
+)
+def test_prune_does_not_retry_an_uncertain_commit(
+    stats: MagicMock, commit_error: Exception
+) -> None:
+    """A lost commit acknowledgement must not spend another prune window."""
+    tables: version_history_retention.ShadowTables = (
+        version_history_retention.ShadowTables(
+            parent=[],
+            child=[],
+            m2m=None,
+            transaction=sa.table("version_transaction", sa.column("id")),
+        )
+    )
+    window: version_history_retention._PruneWindow = (
+        version_history_retention._PruneWindow(
+            prunable=[1], candidate_count=1, max_candidate_id=1
+        )
+    )
+    engine: MagicMock = MagicMock()
+    engine_connection: MagicMock = engine.connect.return_value
+    connection: MagicMock = (
+        engine_connection.execution_options.return_value.__enter__.return_value
+    )
+    transaction: MagicMock = connection.begin.return_value
+    transaction.commit.side_effect = commit_error
+    mock_db: MagicMock
+    sleep: MagicMock
+    with (
+        patch.object(version_history_retention, "db") as mock_db,
+        patch.object(
+            version_history_retention, "_resolve_prune_window", 
return_value=window
+        ),
+        patch.object(
+            version_history_retention, "_delete_for_transactions", 
return_value=0
+        ),
+        patch.object(version_history_retention.time, "sleep") as sleep,
+    ):
+        mock_db.engine = engine
+        with pytest.raises(RuntimeError, match="commit outcome"):
+            version_history_retention._run_pass_with_retry(
+                datetime(2026, 1, 1), tables, after_id=0, max_prune=1
+            )
+
+    assert engine.connect.call_count == 1
+    sleep.assert_not_called()
+    stats.incr.assert_not_called()
+
+
+def test_prune_cap_limits_every_shadow_delete_to_capped_ids() -> None:
+    """Every shadow category and transaction delete uses the same capped 
IDs."""
+    parent: sa.Table = sa.table("parent")
+    child: sa.Table = sa.table("child")
+    m2m: sa.Table = sa.table("m2m")
+    tables: version_history_retention.ShadowTables = (
+        version_history_retention.ShadowTables(
+            parent=[parent],
+            child=[child],
+            m2m=m2m,
+            transaction=sa.table("version_transaction", sa.column("id")),
+        )
+    )
+    window: version_history_retention._PruneWindow = (
+        version_history_retention._PruneWindow(
+            prunable=[1, 2, 3], candidate_count=3, max_candidate_id=3
+        )
+    )
+    engine: MagicMock = MagicMock()
+    connection_context: MagicMock = (
+        engine.connect.return_value.execution_options.return_value
+    )
+    connection: MagicMock = connection_context.__enter__.return_value
+    connection.execute.return_value.rowcount = 0
+    delete: MagicMock
+    mock_db: MagicMock
+    with (
+        patch.object(version_history_retention, "db") as mock_db,
+        patch.object(
+            version_history_retention, "_resolve_prune_window", 
return_value=window
+        ),
+        patch.object(
+            version_history_retention, "_delete_for_transactions", 
return_value=0
+        ) as delete,
+    ):
+        mock_db.engine = engine
+        result: dict[str, Any] = version_history_retention._run_prune_pass(
+            datetime(2026, 1, 1), tables, after_id=0, max_prune=2
+        )
+
+    assert delete.call_args_list == [
+        call(connection, [parent], [1, 2]),
+        call(connection, [child], [1, 2]),
+        call(connection, [m2m], [1, 2]),
+    ]
+    transaction_delete: sa.sql.dml.Delete = 
connection.execute.call_args.args[0]
+    assert transaction_delete.compile().params == {"id_1": [1, 2]}
+    assert result["pruned_transactions"] == 0
+
+
+def test_prune_child_delete_failure_rolls_back_parent_delete() -> None:
+    """A child-delete failure rolls back the pass instead of committing its 
parent."""
+    metadata: sa.MetaData = sa.MetaData()
+    parent: sa.Table = sa.Table("parent", metadata)
+    child: sa.Table = sa.Table("child", metadata)
+    tables: version_history_retention.ShadowTables = (
+        version_history_retention.ShadowTables(
+            parent=[parent],
+            child=[child],
+            m2m=sa.Table("m2m", metadata),
+            transaction=sa.Table(
+                "version_transaction", metadata, sa.Column("id", sa.Integer)
+            ),
+        )
+    )
+    window: version_history_retention._PruneWindow = (
+        version_history_retention._PruneWindow(
+            prunable=[1], candidate_count=1, max_candidate_id=1
+        )
+    )
+    engine: MagicMock = MagicMock()
+    connection_context: MagicMock = (
+        engine.connect.return_value.execution_options.return_value
+    )
+    connection: MagicMock = connection_context.__enter__.return_value
+    transaction: MagicMock = connection.begin.return_value
+    delete_error: RuntimeError = RuntimeError("child-shadow delete failed")
+    delete: MagicMock
+    mock_db: MagicMock
+    raised: pytest.ExceptionInfo[RuntimeError]
+    with (
+        patch.object(version_history_retention, "db") as mock_db,
+        patch.object(
+            version_history_retention, "_resolve_prune_window", 
return_value=window
+        ),
+        patch.object(
+            version_history_retention,
+            "_delete_for_transactions",
+            side_effect=[1, delete_error],
+        ) as delete,
+    ):
+        mock_db.engine = engine
+        with pytest.raises(RuntimeError, match="child-shadow delete failed") 
as raised:
+            version_history_retention._run_prune_pass(
+                datetime(2026, 1, 1), tables, max_prune=1
+            )
+
+    assert raised.value is delete_error
+    assert delete.call_args_list == [
+        call(connection, [parent], [1]),
+        call(connection, [child], [1]),
+    ]
+    transaction.rollback.assert_called_once_with()
+    transaction.commit.assert_not_called()
+    connection.execute.assert_not_called()
+
+
+def test_prune_retries_definitive_db_commit_failure(stats: MagicMock) -> None:
+    """An acknowledged transaction rejection may retry the same capped 
window."""
+    tables: version_history_retention.ShadowTables = (
+        version_history_retention.ShadowTables(
+            parent=[],
+            child=[],
+            m2m=None,
+            transaction=sa.table("version_transaction", sa.column("id")),
+        )
+    )
+    window: version_history_retention._PruneWindow = (
+        version_history_retention._PruneWindow(
+            prunable=[1], candidate_count=1, max_candidate_id=1
+        )
+    )
+    engine: MagicMock = MagicMock()
+    engine_connection: MagicMock = engine.connect.return_value
+    connection: MagicMock = (
+        engine_connection.execution_options.return_value.__enter__.return_value
+    )
+    transaction: MagicMock = connection.begin.return_value
+    commit_error: OperationalError = OperationalError(
+        "COMMIT", {}, Exception("serialization failure")
+    )
+    transaction.commit.side_effect = [commit_error, None]
+    mock_db: MagicMock
+    sleep: MagicMock
+    with (
+        patch.object(version_history_retention, "db") as mock_db,
+        patch.object(
+            version_history_retention, "_resolve_prune_window", 
return_value=window
+        ),
+        patch.object(
+            version_history_retention, "_delete_for_transactions", 
return_value=0
+        ),
+        patch.object(version_history_retention.time, "sleep") as sleep,
+    ):
+        mock_db.engine = engine
+        result: tuple[dict[str, Any], int] = (
+            version_history_retention._run_pass_with_retry(
+                datetime(2026, 1, 1), tables, after_id=0, max_prune=1
+            )
+        )
+
+    assert result[1] == 1
+    assert engine.connect.call_count == 2
+    transaction.rollback.assert_called_once()
+    sleep.assert_called_once()
+    stats.incr.assert_called_once_with("superset.versioning.retention.retried")
+
+
+def test_prune_failed_rollback_stops_retry(stats: MagicMock) -> None:
+    """A failed rollback cannot be classified as a retryable rejection."""
+    tables: version_history_retention.ShadowTables = (
+        version_history_retention.ShadowTables(
+            parent=[],
+            child=[],
+            m2m=None,
+            transaction=sa.table("version_transaction", sa.column("id")),
+        )
+    )
+    window: version_history_retention._PruneWindow = (
+        version_history_retention._PruneWindow(
+            prunable=[1], candidate_count=1, max_candidate_id=1
+        )
+    )
+    engine: MagicMock = MagicMock()
+    engine_connection: MagicMock = engine.connect.return_value
+    connection: MagicMock = (
+        engine_connection.execution_options.return_value.__enter__.return_value
+    )
+    transaction: MagicMock = connection.begin.return_value
+    transaction.commit.side_effect = OperationalError(
+        "COMMIT", {}, Exception("transaction rejected")
+    )
+    transaction.rollback.side_effect = RuntimeError("rollback failed")
+    mock_db: MagicMock
+    sleep: MagicMock
+    with (
+        patch.object(version_history_retention, "db") as mock_db,
+        patch.object(
+            version_history_retention, "_resolve_prune_window", 
return_value=window
+        ),
+        patch.object(
+            version_history_retention, "_delete_for_transactions", 
return_value=0
+        ),
+        patch.object(version_history_retention.time, "sleep") as sleep,
+    ):
+        mock_db.engine = engine
+        with pytest.raises(
+            version_history_retention._PruneRollbackFailedError,
+            match="rollback failed",
+        ):
+            version_history_retention._run_pass_with_retry(
+                datetime(2026, 1, 1), tables, after_id=0, max_prune=1
+            )
+
+    assert engine.connect.call_count == 1
+    sleep.assert_not_called()
+    stats.incr.assert_not_called()
+
+
+def test_scheduled_prune_rejects_invalid_cap_before_work(stats: MagicMock) -> 
None:
+    """A malformed scheduled budget cannot enter the prune implementation."""
+    app: Flask = Flask(__name__)
+    app.config.update(
+        VERSION_HISTORY_RETENTION_DAYS=30,
+        VERSION_HISTORY_PRUNE_MAX_TRANSACTIONS_PER_RUN=-1,
+    )
+    prune: MagicMock
+    with (
+        app.app_context(),
+        patch.object(version_history_retention, "_prune_old_versions_impl") as 
prune,
+    ):
+        result: dict[str, Any] = version_history_retention.prune_old_versions()
+
+    assert result == {"skipped_invalid_cap": 1}
+    prune.assert_not_called()
+    stats.incr.assert_called_once_with(
+        "superset.versioning.retention.skipped_invalid_cap"
+    )
+
+
+def test_scheduled_prune_passes_dry_run_and_cap_to_impl() -> None:
+    """The scheduled wrapper preserves both operator-selected controls."""
+    app: Flask = Flask(__name__)
+    app.config.update(
+        VERSION_HISTORY_RETENTION_DAYS=30,
+        VERSION_HISTORY_PRUNE_DRY_RUN=True,
+        VERSION_HISTORY_PRUNE_MAX_TRANSACTIONS_PER_RUN=2,
+    )
+    prune: MagicMock
+    with (
+        app.app_context(),
+        patch.object(
+            version_history_retention, "_prune_old_versions_impl", 
return_value={}
+        ) as prune,
+    ):
+        result: dict[str, Any] = version_history_retention.prune_old_versions()
+
+    assert result == {}
+    prune.assert_called_once_with(30, max_per_run=2, dry_run=True)
+
+
[email protected]("invalid", [None, 0, 1, "false", [], {}])
+def test_scheduled_prune_skips_invalid_dry_run_before_work(
+    stats: MagicMock, caplog: pytest.LogCaptureFixture, invalid: object
+) -> None:
+    """Malformed dry-run configuration skips without marking the task 
failed."""
+    app: Flask = Flask(__name__)
+    app.config.update(
+        VERSION_HISTORY_RETENTION_DAYS=30,
+        VERSION_HISTORY_PRUNE_DRY_RUN=invalid,
+    )
+    prune: MagicMock
+    with (
+        app.app_context(),
+        patch.object(version_history_retention, "_prune_old_versions_impl") as 
prune,
+    ):
+        result: dict[str, Any] = version_history_retention.prune_old_versions()
+
+    assert result == {"skipped_invalid_dry_run": 1}
+    prune.assert_not_called()
+    stats.incr.assert_called_once_with(
+        "superset.versioning.retention.skipped_invalid_dry_run"
+    )
+    assert "invalid prune dry-run" in caplog.text
+
+
+def test_remainder_probe_failure_does_not_mask_committed_prune(
+    stats: MagicMock,
+) -> None:
+    """A measurement fault leaves committed deletion totals successful."""
+    tables: version_history_retention.ShadowTables = (
+        version_history_retention.ShadowTables(
+            parent=[], child=[], m2m=None, transaction=MagicMock()
+        )
+    )
+    with (
+        patch.object(
+            version_history_retention, "_resolve_shadow_tables", 
return_value=tables
+        ),
+        patch.object(
+            version_history_retention,
+            "_run_pass_with_retry",
+            return_value=({"pruned_transactions": 2}, 0),
+        ),
+        patch.object(
+            version_history_retention,
+            "_probe_prunable",
+            side_effect=OperationalError("SELECT 1", {}, Exception("database 
offline")),
+        ),
+    ):
+        result: dict[str, Any] = 
version_history_retention._prune_old_versions_impl(
+            30, max_per_run=2
+        )
+
+    assert result["pruned_transactions"] == 2
+    assert result["cap_reached"] is True
+    assert result["remaining_eligible"] is None
+    assert result["remaining_count_complete"] is False
+    stats.incr.assert_called_once_with(
+        "superset.versioning.retention.remainder_probe_failed"
+    )

Reply via email to