Skip to content

feat(deletion-retention): scheduled pruning policy for the purge audit log - #43490

Open
mikebridge wants to merge 17 commits into
apache:masterfrom
mikebridge:sc-116701-purge-audit-pruning
Open

feat(deletion-retention): scheduled pruning policy for the purge audit log#43490
mikebridge wants to merge 17 commits into
apache:masterfrom
mikebridge:sc-116701-purge-audit-pruning

Conversation

@mikebridge

@mikebridge mikebridge commented Aug 24, 2026

Copy link
Copy Markdown
Contributor

SUMMARY

In plain language. When Superset permanently deletes ("purges") old soft-deleted dashboards and charts under a retention policy, it writes a small metadata-only record of each attempt — a shredder's logbook: not the documents, just "on this date we tried to purge X; result: succeeded / failed / blocked." That logbook grows forever. This PR adds a scheduled janitor that trims old entries — housekeeping for the housekeeping log.

The care in the logic is about which entries are safe to trim. The valuable ones record that an object was blocked from deletion. A purge is blocked when removing the object would break something still pointing at it — an alert/report still scheduled on it (report_schedule), a user attribute referencing it (user_attribute), or a referential-integrity dependency in the delete cascade (cascade_integrity_failure). (A failed record is different: the delete was attempted and the database cascade actually errored — an infrastructure problem, not a policy refusal.)

If one object keeps getting blocked you accumulate many near-identical "blocked" slips; you only need the earliest one — "blocked since <date>" — and only while the blockage is still ongoing (a streak). Most of this PR is the rules that keep that earliest slip alive until the object is actually gone, without ever mistaking an old block for a current one.

The purge_audit_log table grew without bound. Every scheduled purge run that hit the same blocked entity wrote another identical record (until #42863 deduped the write path), and nothing ever aged out operational noise. This adds the retention policy defined by SC-116701 and the scheduled task that applies it. The pruning logic is deletion-only, but the PR carries two supporting, reversible migrations: a covering index on purge_audit_log for the prune queries (a6c21e5b4d93) and a small coordination table seeded on upgrade (c7f53d184ea2). Both have upgrade+downgrade unit tests.

Three categories, drained in priority order under one shared per-run budget:

  1. Blocked duplicates. Within an entity's current blockage streak, only the earliest row survives — it carries the "blocked since" fact and is never deleted while the streak is current. Later duplicates go regardless of age; that is the rule that actually bounds growth.
  2. Operational expiry. failed rows and blocked rows from resolved streaks age out past PURGE_AUDIT_OPERATIONAL_RETENTION_DAYS (default 90).
  3. Evidence expiry. confirmed / target_absent — the only surviving trace of a destroyed object — are never touched unless PURGE_AUDIT_EVIDENCE_RETENTION_DAYS is explicitly set. Setting it is the operator's assertion that an approved compliance policy permits expiring destruction evidence.

pending rows belong to reconcile_pending() and are structurally unreachable. Invalid retention values fail closed: the run logs a warning and skips that category rather than widening removal. PURGE_AUDIT_PRUNING_ENABLED = False restores the previous behaviour exactly, and a disabled run says so rather than silently doing nothing.

Design notes worth a reviewer's attention:

  • A failed attempt does not end a blockage streak. A failed purge is an infrastructure outcome — the cascade raised — not evidence the block cleared; the blocking policy is untouched. Treating it as a boundary would let one transient error demote the blocked-since survivor and restate the blockage as beginning after the failure.
  • Concurrency. Deletes are conditional on the expected status and counted from statement rowcounts, so overlapping runs cannot double-remove or double-report. Beyond that, a blocked row whose streak classification is unstable is never deleted by either category: finalizing a pending row resolves it in place, keeping its original timestamp, so an unresolved attempt is a boundary that can appear mid-history and turn the row after it into a survivor. Evidence expiry likewise refuses to delete a row that still bounds surviving blocked rows, which is what stops a boundary receding — and makes an evidence window shorter than the operational one safe rather than corrupting.
  • Backdated-write race — closed. Each batch discovers up to BATCH_SIZE candidate ids with an unlocked SELECT, then — under the same singleton coordination lock the audit writer takes before stamping created_onre-checks those ids with a SELECT that re-applies the identical candidacy predicates over current committed state, and deletes only the survivors by literal id. Re-verifying candidacy under the lock is what closes the race: a row that becomes visible with a past created_on between discovery and the lock fails the re-check and is spared, and the lock serializes pruning against concurrent writers. The re-check is a SELECT rather than a self-referencing DELETE subquery because MySQL rejects the latter (ERROR 1093). The discovery transaction is ended before the lock is taken, so the re-check reads a fresh snapshot and is correct regardless of isolation level — Superset pins MySQL to READ COMMITTED, so shipped config was already safe; this is isolation-independence hardening. (created_on remains writer-stamped, not database-assigned; the lock plus the locked re-check are what close the race, so no created_on migration is needed.)
  • Each category is guaranteed at least one batch of the shared budget. Strict priority would let a permanent duplicate backlog starve age-out forever — reintroducing the unbounded growth this feature exists to stop.
  • The startup warning that already covers the other retention tasks now covers this beat entry too, so an operator who replaced CELERY_CONFIG is told pruning is not running.

This PR was reviewed by a nine-lens panel plus three adversarial data-systems passes before opening; the survivor-invariant findings from those passes are fixed here, each with a red-first control run.

BEFORE/AFTER SCREENSHOTS OR ANIMATED GIF

No UI. Operator-visible surface is configuration, metrics (deletion_retention.prune_purge_audit.* — per-category removal counts plus carried_over), and one structured completion log line per run.

TESTING INSTRUCTIONS

# Unit
pytest tests/unit_tests/commands/deletion_retention/test_prune_audit.py

# Integration (needs a metadata DB; run against Postgres)
pytest tests/integration_tests/deletion_retention/prune_audit_tests.py

26 unit tests (classification and the exhaustiveness contract against the model's ALL_STATUSES, fail-closed config validation, task reporting/isolation, clock parity with the audit writer) and 20 integration tests against the real table: age-independent dedup, bounded convergence with carryover, cross-entity isolation, resolved streaks, failed streak-transparency, evidence protection by count-and-identity, opt-in expiry and its disable-again edge, pending/future-row immunity, skew-resistant classification, unstable-block deferral in both categories, boundary-guard deferral including the inverted-window case, uuid-less block retention, starvation resistance, rerun idempotence, and the status re-check.

Manual: enable the beat entry, seed duplicate blocked rows for one entity, run deletion_retention.prune_purge_audit, and confirm the earliest row survives and the counts appear in logs/metrics. Full recipe in specs/sc-116701-purge-audit-pruning/quickstart.md.

ADDITIONAL INFORMATION

  • Has associated issue: SC-116701 (follow-up to SC-115343 / fix(deletion-retention): dedupe repeated blocked audits #42863, part of the soft-delete work under the approved SIP-208)
  • Required feature flags: none — pruning is independent of SOFT_DELETE because audit rows outlive the flag. The beat-entry startup warning is gated on it.
  • Changes UI
  • Includes DB Migration (follow approval process in SIP-59) — a reversible covering index (a6c21e5b4d93) plus a coordination table (c7f53d184ea2)
    • Migration is atomic, supports rollback & is backwards-compatible — index add + new table only; downgrades drop them, no existing column/data touched
    • Confirm DB migration upgrade and downgrade tested — tests/unit_tests/migrations/test_purge_audit_coordination.py exercises upgrade + downgrade for both
    • Runtime estimates and downtime expectations provided
  • Introduces new feature or API — new Celery task, three config keys, no REST surface
  • Removes existing feature or API

Behaviour change on upgrade (recorded in UPDATING.md): operational audit records older than 90 days begin pruning automatically. Completed-destruction evidence is untouched. Opt out with PURGE_AUDIT_PRUNING_ENABLED = False; deployments overriding CELERY_CONFIG must carry the new beat entry forward.

This PR was developed with AI assistance (Claude Code), including the implementation, tests, and review remediation; a human (@mikebridge) reviews before merge.

@dosubot dosubot Bot added the change:backend Requires changing the backend label Aug 24, 2026
@bito-code-review

bito-code-review Bot commented Aug 24, 2026

Copy link
Copy Markdown
Contributor

Code Review Agent Run #9ce86e

Actionable Suggestions - 0
Additional Suggestions - 1
  • superset/initialization/__init__.py - 1
    • Missing negative test case for prune_audit warning · Line 1045-1045
      The new prune_audit warning block (lines 1045-1054) lacks a corresponding "off-flag" negative test, creating an asymmetry with the purge task's test coverage. The existing test_no_purge_warn_when_soft_delete_off validates that the purge warning is suppressed when SOFT_DELETE is off; the same pattern should guard the audit-prune check.
Filtered by Review Rules

Bito filtered these suggestions based on rules created automatically for your feedback. Manage rules.

  • superset/initialization/__init__.py - 1
Review Details
  • Files reviewed - 9 · Commit Range: 405f5cb..e2e624f
    • superset/commands/deletion_retention/audit.py
    • superset/commands/deletion_retention/prune_audit.py
    • superset/config.py
    • superset/initialization/__init__.py
    • superset/models/purge_audit_log.py
    • superset/tasks/deletion_retention.py
    • tests/integration_tests/deletion_retention/prune_audit_tests.py
    • tests/unit_tests/commands/deletion_retention/test_prune_audit.py
    • tests/unit_tests/initialization_test.py
  • Files skipped - 1
    • UPDATING.md - Reason: Filter setting
  • Tools
    • MyPy (Static Code Analysis) - ✔︎ Successful
    • Astral Ruff (Static Code Analysis) - ✔︎ Successful
    • Whispers (Secret Scanner) - ✔︎ Successful
    • Detect-secrets (Secret Scanner) - ✔︎ Successful

Bito Usage Guide

Commands

Type the following command in the pull request comment and save the comment.

  • /review - Manually triggers an incremental AI Review.

  • /review full - Manually triggers a full AI Review.

  • /pause - Pauses automatic reviews on this pull request.

  • /resume - Resumes automatic reviews.

  • /resolve - Marks all Bito-posted review comments as resolved.

  • /abort - Cancels all in-progress reviews.

Refer to the documentation for additional commands.

Configuration

This repository uses Superset You can customize the agent settings here or contact your Bito workspace admin at evan@preset.io.

Documentation & Help

AI Code Review powered by Bito Logo

@mikebridge

Copy link
Copy Markdown
Contributor Author

Coordination note for whoever merges this: #43485 (SC-115342, block-reason column) touches the same subsystem and four of the same files — superset/commands/deletion_retention/audit.py, superset/models/purge_audit_log.py, superset/tasks/deletion_retention.py, and UPDATING.md.

The two changes are semantically independent: that one adds a nullable reason column with a migration; this one is deletion-only and adds no schema, so there is no alembic chaining to manage in either direction. The overlaps are textual only — here they are a public utc_now in audit.py (was _utc_now, now shared with the pruner so cutoffs and created_on cannot drift apart), an ALL_STATUSES constant on the model, a new task in tasks/deletion_retention.py, and a new UPDATING.md entry.

Whichever lands second rebases; no ordering preference from this side. One thing worth knowing if the reason column ever grows the status vocabulary: this PR adds an exhaustiveness test asserting every status in ALL_STATUSES falls into exactly one retention category, so a new status without a category fails a test rather than silently never being pruned.

Comment by Claude (AI) on behalf of @mikebridge.

@netlify

netlify Bot commented Aug 24, 2026

Copy link
Copy Markdown

Deploy Preview for superset-docs-preview ready!

Name Link
🔨 Latest commit d827a53
🔍 Latest deploy log https://app.netlify.com/projects/superset-docs-preview/deploys/6aa2289e8bd344000827f4bb
😎 Deploy Preview https://deploy-preview-43490--superset-docs-preview.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.
🤖 Make changes Run an agent on this branch

To edit notification comments on pull requests, go to your Netlify project configuration.

@codecov

codecov Bot commented Aug 24, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 99.06542% with 2 lines in your changes missing coverage. Please review.
✅ Project coverage is 80.16%. Comparing base (6420535) to head (ad34aee).
⚠️ Report is 4 commits behind head on master.

Files with missing lines Patch % Lines
superset/commands/deletion_retention/audit.py 80.00% 1 Missing and 1 partial ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##           master   #43490      +/-   ##
==========================================
+ Coverage   80.13%   80.16%   +0.02%     
==========================================
  Files        2924     2925       +1     
  Lines      172288   172535     +247     
  Branches    39995    40019      +24     
==========================================
+ Hits       138060   138309     +249     
+ Misses      31642    31639       -3     
- Partials     2586     2587       +1     
Flag Coverage Δ
hive 37.35% <40.18%> (-0.01%) ⬇️
mysql 56.96% <82.24%> (+0.06%) ⬆️
postgres 56.99% <82.24%> (+0.06%) ⬆️
presto 39.23% <40.18%> (-0.01%) ⬇️
python 84.54% <99.06%> (+0.04%) ⬆️
sqlite 56.68% <80.84%> (+0.06%) ⬆️
unit 75.79% <71.96%> (-0.01%) ⬇️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@github-actions github-actions Bot added the risk:db-migration PRs that require a DB migration label Aug 24, 2026

@bito-code-review bito-code-review Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review Agent Run #398665

Actionable Suggestions - 4
  • superset/migrations/versions/2026-08-24_15-50_a6c21e5b4d93_index_purge_audit_pruning.py - 1
    • Missing unit test coverage for new migration · Line 35-46
  • tests/unit_tests/commands/deletion_retention/test_prune_audit.py - 1
  • tests/integration_tests/deletion_retention/prune_audit_tests.py - 2
Filtered by Review Rules

Bito filtered these suggestions based on rules created automatically for your feedback. Manage rules.

  • tests/unit_tests/commands/deletion_retention/test_prune_audit.py - 1
  • superset/initialization/__init__.py - 1
  • superset/commands/deletion_retention/prune_audit.py - 1
Review Details
  • Files reviewed - 14 · Commit Range: e2e624f..831b571
    • superset/commands/deletion_retention/audit.py
    • superset/commands/deletion_retention/prune_audit.py
    • superset/config.py
    • superset/initialization/__init__.py
    • superset/migrations/versions/2026-08-24_15-50_a6c21e5b4d93_index_purge_audit_pruning.py
    • superset/migrations/versions/2026-08-24_16-20_c7f53d184ea2_coordinate_purge_audit_pruning.py
    • superset/models/purge_audit_log.py
    • superset/tasks/deletion_retention.py
    • tests/integration_tests/deletion_retention/_base.py
    • tests/integration_tests/deletion_retention/audit_tests.py
    • tests/integration_tests/deletion_retention/prune_audit_tests.py
    • tests/unit_tests/commands/deletion_retention/test_prune_audit.py
    • tests/unit_tests/initialization_test.py
    • tests/unit_tests/migrations/test_purge_audit_coordination.py
  • Files skipped - 1
    • UPDATING.md - Reason: Filter setting
  • Tools
    • MyPy (Static Code Analysis) - ✔︎ Successful
    • Astral Ruff (Static Code Analysis) - ✔︎ Successful
    • Whispers (Secret Scanner) - ✔︎ Successful
    • Detect-secrets (Secret Scanner) - ✔︎ Successful

Bito Usage Guide

Commands

Type the following command in the pull request comment and save the comment.

  • /review - Manually triggers an incremental AI Review.

  • /review full - Manually triggers a full AI Review.

  • /pause - Pauses automatic reviews on this pull request.

  • /resume - Resumes automatic reviews.

  • /resolve - Marks all Bito-posted review comments as resolved.

  • /abort - Cancels all in-progress reviews.

Refer to the documentation for additional commands.

Configuration

This repository uses Superset You can customize the agent settings here or contact your Bito workspace admin at evan@preset.io.

Documentation & Help

AI Code Review powered by Bito Logo

Comment thread tests/unit_tests/commands/deletion_retention/test_prune_audit.py
Comment thread tests/integration_tests/deletion_retention/prune_audit_tests.py Outdated
Comment thread tests/integration_tests/deletion_retention/prune_audit_tests.py Outdated

@rusackas rusackas left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks @mikebridge, LGTM. Spent real time on the survivor/boundary logic and the coordination lock since this is deletion logic on an audit table: the lock is a plain UPDATE on a singleton row rather than SELECT FOR UPDATE (SQLite doesn't support that), held through commit, and since now gets captured before any lock is taken, a concurrent writer's row always lands at or after that cutoff, so it's excluded from the run rather than racing into it. The clock-skew gap is the one the docstring already calls out. Bot threads all look properly resolved rather than waved off.

Comment thread superset/commands/deletion_retention/prune_audit.py Outdated
@mikebridge
mikebridge force-pushed the sc-116701-purge-audit-pruning branch from 831b571 to 4592c42 Compare August 25, 2026 20:19
Comment thread superset/initialization/__init__.py Outdated
@bito-code-review

bito-code-review Bot commented Aug 25, 2026

Copy link
Copy Markdown
Contributor

Code Review Agent Run #79f175

Actionable Suggestions - 0
Additional Suggestions - 2
  • superset/commands/deletion_retention/prune_audit.py - 2
    • Magic number in batch budget · Line 130-130
      Hardcoded batch size prevents operators from tuning deletion throughput without code changes. FR-004 references a shared batch budget but the budget size itself is not operator-configurable. Move to a config key like the retention windows.
    • Hardcoded batch budget limit · Line 133-133
      The per-run batch budget limit is hardcoded. This directly controls how much work one pruning run performs before yielding to the scheduler. Operators who need different throughput/backpressure behavior must modify source code.
Filtered by Review Rules

Bito filtered these suggestions based on rules created automatically for your feedback. Manage rules.

  • tests/integration_tests/deletion_retention/prune_audit_tests.py - 1
    • Uninitialized instance attribute on first use · Line 65-65
  • tests/integration_tests/deletion_retention/audit_tests.py - 1
    • Catch specific exception instead of BaseException · Line 103-103
Review Details
  • Files reviewed - 14 · Commit Range: 5b0922b..79ebceb
    • superset/commands/deletion_retention/audit.py
    • superset/commands/deletion_retention/prune_audit.py
    • superset/config.py
    • superset/initialization/__init__.py
    • superset/migrations/versions/2026-08-24_15-50_a6c21e5b4d93_index_purge_audit_pruning.py
    • superset/migrations/versions/2026-08-24_16-20_c7f53d184ea2_coordinate_purge_audit_pruning.py
    • superset/models/purge_audit_log.py
    • superset/tasks/deletion_retention.py
    • tests/integration_tests/deletion_retention/_base.py
    • tests/integration_tests/deletion_retention/audit_tests.py
    • tests/integration_tests/deletion_retention/prune_audit_tests.py
    • tests/unit_tests/commands/deletion_retention/test_prune_audit.py
    • tests/unit_tests/initialization_test.py
    • tests/unit_tests/migrations/test_purge_audit_coordination.py
  • Files skipped - 1
    • UPDATING.md - Reason: Filter setting
  • Tools
    • MyPy (Static Code Analysis) - ✔︎ Successful
    • Astral Ruff (Static Code Analysis) - ✔︎ Successful
    • Whispers (Secret Scanner) - ✔︎ Successful
    • Detect-secrets (Secret Scanner) - ✔︎ Successful

Bito Usage Guide

Commands

Type the following command in the pull request comment and save the comment.

  • /review - Manually triggers an incremental AI Review.

  • /review full - Manually triggers a full AI Review.

  • /pause - Pauses automatic reviews on this pull request.

  • /resume - Resumes automatic reviews.

  • /resolve - Marks all Bito-posted review comments as resolved.

  • /abort - Cancels all in-progress reviews.

Refer to the documentation for additional commands.

Configuration

This repository uses Superset You can customize the agent settings here or contact your Bito workspace admin at evan@preset.io.

Documentation & Help

AI Code Review powered by Bito Logo

Comment thread superset/commands/deletion_retention/prune_audit.py Outdated
mikebridge pushed a commit to mikebridge/superset that referenced this pull request Sep 1, 2026
…p ties when pruning

Address the review threads on apache#43490:

* A blockage streak's survivors are now the first row of each run of
  consecutive same-reason blocked rows — the streak's earliest row and the
  first row after every change of block reason — instead of the earliest
  row only. This is the audit writer's own suppression rule
  (finalize_retention_blocked) applied retroactively; previously a
  `report_schedule` block followed by a `cascade_integrity_failure` block
  lost the only durable evidence of the latter on the next prune. Run
  heads are exempt from age-out like the earliest row was; resolved-streak
  run heads still age out. The reason comparison is NULL-safe on every
  supported dialect so pre-feature rows form a run of their own that the
  first coded block ends, exactly as at write time.

* Timestamp ties (legacy second-precision rows, two writers in one clock
  tick) are resolved on the preserving side, consistently: a pending row
  tied with a blocked row counts as preceding it (the block is deferred
  until the attempt resolves); a blocked row tied with a boundary sits on
  the boundary's resolved side (it ages out rather than seeding a new
  current streak) and the evidence guard now keeps the boundary until that
  tied row is gone; tied same-reason blocked rows are all retained.

* Startup diagnostics warn when PURGE_AUDIT_PRUNING_ENABLED is not a
  boolean. The task already failed closed on such values, but the
  initializer collapsed them into "disabled" and emitted nothing, so a
  typo like "true" left the audit log growing without any warning.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01267VBWbvWTNZUg9GvXKgkC
@mikebridge

Copy link
Copy Markdown
Contributor Author

Pushed 86e86b4 addressing the three open threads (reason transitions now survive pruning, timestamp ties resolve on the preserving side, non-boolean PURGE_AUDIT_PRUNING_ENABLED warns at startup) — details on each thread. @sadpandajoe @rusackas ready for another look when you have a moment.

@bito-code-review

bito-code-review Bot commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

Code Review Agent Run #ec1a4d

Actionable Suggestions - 0
Additional Suggestions - 1
  • superset/commands/deletion_retention/prune_audit.py - 1
    • Tied reason-change misclassified · Line 311-311
      In `_repeats_an_earlier_block`, `reason_changed_between` uses strict `between.c.created_on > earlier.c.created_on`. For A(X)@t, B(Y)@t (tied), C(X)@t2, B is not seen as a reason change, so C is misclassified as a repeat of A and deleted — yet C is the first block after a change back to X. This contradicts the module's tie-on-preserving-side rule (lines 72-78). Use `>=` so a tied reason-change row counts.
Review Details
  • Files reviewed - 6 · Commit Range: 79ebceb..86e86b4
    • superset/commands/deletion_retention/prune_audit.py
    • superset/config.py
    • superset/initialization/__init__.py
    • tests/integration_tests/deletion_retention/prune_audit_tests.py
    • tests/unit_tests/commands/deletion_retention/test_prune_audit.py
    • tests/unit_tests/initialization_test.py
  • Files skipped - 1
    • UPDATING.md - Reason: Filter setting
  • Tools
    • MyPy (Static Code Analysis) - ✔︎ Successful
    • Astral Ruff (Static Code Analysis) - ✔︎ Successful
    • Whispers (Secret Scanner) - ✔︎ Successful
    • Detect-secrets (Secret Scanner) - ✔︎ Successful

Bito Usage Guide

Commands

Type the following command in the pull request comment and save the comment.

  • /review - Manually triggers an incremental AI Review.

  • /review full - Manually triggers a full AI Review.

  • /pause - Pauses automatic reviews on this pull request.

  • /resume - Resumes automatic reviews.

  • /resolve - Marks all Bito-posted review comments as resolved.

  • /abort - Cancels all in-progress reviews.

Refer to the documentation for additional commands.

Configuration

This repository uses Superset You can customize the agent settings here or contact your Bito workspace admin at evan@preset.io.

Documentation & Help

AI Code Review powered by Bito Logo

mikebridge pushed a commit to mikebridge/superset that referenced this pull request Sep 1, 2026
…p ties when pruning

Address the review threads on apache#43490:

* A blockage streak's survivors are now the first row of each run of
  consecutive same-reason blocked rows — the streak's earliest row and the
  first row after every change of block reason — instead of the earliest
  row only. This is the audit writer's own suppression rule
  (finalize_retention_blocked) applied retroactively; previously a
  `report_schedule` block followed by a `cascade_integrity_failure` block
  lost the only durable evidence of the latter on the next prune. Run
  heads are exempt from age-out like the earliest row was; resolved-streak
  run heads still age out. The reason comparison is NULL-safe on every
  supported dialect so pre-feature rows form a run of their own that the
  first coded block ends, exactly as at write time.

* Timestamp ties (legacy second-precision rows, two writers in one clock
  tick) are resolved on the preserving side, consistently: a pending row
  tied with a blocked row counts as preceding it (the block is deferred
  until the attempt resolves); a blocked row tied with a boundary sits on
  the boundary's resolved side (it ages out rather than seeding a new
  current streak) and the evidence guard now keeps the boundary until that
  tied row is gone; tied same-reason blocked rows are all retained.

* Startup diagnostics warn when PURGE_AUDIT_PRUNING_ENABLED is not a
  boolean. The task already failed closed on such values, but the
  initializer collapsed them into "disabled" and emitted nothing, so a
  typo like "true" left the audit log growing without any warning.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01267VBWbvWTNZUg9GvXKgkC
@mikebridge
mikebridge force-pushed the sc-116701-purge-audit-pruning branch from 86e86b4 to a24a131 Compare September 1, 2026 20:48
@mikebridge

Copy link
Copy Markdown
Contributor Author

Heads-up for re-review: I ran a focused data-systems/python/sqlalchemy review over the last delta (the reason-transition dedup + tie policy, 79ebceba18..86e86b4078) and it surfaced one real edge case, now fixed in a24a1313fe:

  • Differing-reason timestamp tie could delete a reason-transition run-head. _repeats_an_earlier_block used strict >/< bounds, so a differing-reason block sharing an exact created_on with a run's earlier endpoint wasn't counted as a boundary — with A(X)@t0, a tied A(X)@t1/B(Y)@t1, and a later A(X)@t2, the last A (the first block after the reason returned to X) was pruned as a duplicate, dropping a reason-transition record. Fixed by making the boundary bounds inclusive (>=/<=), matching the preserving-side tie rule the pending and evidence guards already use; inclusive bounds can only ever preserve more, never delete more. New test_a_tied_reason_change_still_breaks_the_run fails on the old bounds and passes on the fix.

Also in the same amend: corrected a docstring that overclaimed writer-parity for reason-less (pre-feature) runs (the pruner is stricter there — it collapses legacy duplicates; the writer never suppresses a reason-less block), and added an A,A,B,A,A reason-return test. Integration suite 28/28 on Postgres + SQLite, unit 83/83, pre-commit clean.

Sorry to bounce the approval — flagging so it gets a fresh look at the new head.

@bito-code-review

bito-code-review Bot commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

Code Review Agent Run #f0cda0

Actionable Suggestions - 0
Additional Suggestions - 5
  • superset/migrations/versions/2026-08-24_16-20_c7f53d184ea2_coordinate_purge_audit_pruning.py - 1
    • Unguarded sentinel seed insert · Line 52-55
      `create_table` is idempotent (skips if the table exists), but the seed `op.bulk_insert` of the fixed primary key `_SENTINEL_ID` is unguarded. On a re-run of this revision, the insert raises a duplicate-key error and blocks `superset db upgrade`. Guard the insert on row existence to match the helper's re-run intent.
  • superset/commands/deletion_retention/audit.py - 1
    • Uncaught coordination error · Line 369-369
      `acquire_coordination_lock` raises `PurgeAuditCoordinationError` (a `RuntimeError`), but the enclosing `except SQLAlchemyError` at line 399 won't catch it. A missing/indeterminate sentinel would escape this fail-safe recovery path, abort blocked-evidence recovery, and surface as a cascade failure in `_purge_model` instead of returning "fallback". Catch `PurgeAuditCoordinationError` alongside `SQLAlchemyError`.
  • tests/integration_tests/deletion_retention/audit_tests.py - 1
    • Weak lock-contention assertion · Line 114-115
      `waiting_started` is set before the thread calls `acquire_coordination_lock`, so `assert not waiting_acquired.wait(timeout=0.1)` can pass trivially if the thread hasn't reached the lock yet. Signal that the lock attempt was actually entered (e.g. a `waiting_entering_lock` Event set right before the call) before asserting it is blocked, so the test genuinely verifies serialization.
  • superset/initialization/__init__.py - 1
    • Audit-prune warning gap · Line 1061-1074
      The `not soft_delete_enabled` gate means that when SOFT_DELETE is on and `deletion_retention` is missing from `imports`, only the purge warning (lines 1038-1050) fires — it never mentions the audit log. Since `prune_purge_audit` is deliberately not gated on SOFT_DELETE, the audit-prune task is broken in this case too, but the operator isn't told. Consider mentioning the audit log in the purge warning or deduping the module checks.
  • superset/commands/deletion_retention/prune_audit.py - 1
    • Silent else miscounts category · Line 589-590
      The bare `else` silently routes any unrecognized category to `result.evidence_expired`. `_CategoryName` is a closed Literal today, but if a fourth category is added to the list without updating this function, its removal count would be misattributed, corrupting the per-category metrics and log line in `prune_purge_audit`. Consider an explicit `elif` plus a `ValueError` fallback to match the fail-closed posture used elsewhere.
Review Details
  • Files reviewed - 14 · Commit Range: 5b0922b..a24a131
    • superset/commands/deletion_retention/audit.py
    • superset/commands/deletion_retention/prune_audit.py
    • superset/config.py
    • superset/initialization/__init__.py
    • superset/migrations/versions/2026-08-24_15-50_a6c21e5b4d93_index_purge_audit_pruning.py
    • superset/migrations/versions/2026-08-24_16-20_c7f53d184ea2_coordinate_purge_audit_pruning.py
    • superset/models/purge_audit_log.py
    • superset/tasks/deletion_retention.py
    • tests/integration_tests/deletion_retention/_base.py
    • tests/integration_tests/deletion_retention/audit_tests.py
    • tests/integration_tests/deletion_retention/prune_audit_tests.py
    • tests/unit_tests/commands/deletion_retention/test_prune_audit.py
    • tests/unit_tests/initialization_test.py
    • tests/unit_tests/migrations/test_purge_audit_coordination.py
  • Files skipped - 1
    • UPDATING.md - Reason: Filter setting
  • Tools
    • MyPy (Static Code Analysis) - ✔︎ Successful
    • Astral Ruff (Static Code Analysis) - ✔︎ Successful
    • Whispers (Secret Scanner) - ✔︎ Successful
    • Detect-secrets (Secret Scanner) - ✔︎ Successful

Bito Usage Guide

Commands

Type the following command in the pull request comment and save the comment.

  • /review - Manually triggers an incremental AI Review.

  • /review full - Manually triggers a full AI Review.

  • /pause - Pauses automatic reviews on this pull request.

  • /resume - Resumes automatic reviews.

  • /resolve - Marks all Bito-posted review comments as resolved.

  • /abort - Cancels all in-progress reviews.

Refer to the documentation for additional commands.

Configuration

This repository uses Superset You can customize the agent settings here or contact your Bito workspace admin at evan@preset.io.

Documentation & Help

AI Code Review powered by Bito Logo

@aminghadersohi aminghadersohi left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed at a24a1313fedcd97c958877fc1c5fcbb811e5a43f. External-contributor review, so this is a COMMENT only — no approval implied either way.


🔴 First, before anything else: the APPROVED badge on this PR is stale

GitHub reports reviewDecision: APPROVED, which makes this PR look one-click mergeable. It is not. There is exactly one approving review on the PR, and it is not at head:

rusackas  APPROVED  @ 831b571c8529ec5ca22a0aec9d35bffbaddcb296   2026-08-25T19:50:40Z
LIVE HEAD           = a24a1313fedcd97c958877fc1c5fcbb811e5a43f   2026-09-01T17:14:23Z

GET /repos/apache/superset/pulls/43490/reviews returns no APPROVED review whose commit_id equals headRefOid. Worse — 831b571c85 is not among the seven commits returned by GET /pulls/43490/commits at all. The approved tree is unreachable from head: the branch was rebased after the approval.

(Set membership is the reliable test here, not timestamps. Five of the seven commits carry author dates predating the approval, which looks reassuring and is not — their committer dates are 2026-08-25T20:14–20:19Z, roughly 25 minutes after the approval. That gap is the rebase.)

The seven commits now on the branch:

5b0922bc8d  feat(deletion-retention): scheduled pruning policy for the purge audit log
961a87bec1  fix: close survivor-invariant holes found in review
8dd78c9d39  fix: close the promotion path an adversarial re-review found
99389d208e  fix: apply the unstable-block guard to age-based expiry
4592c4217b  fix: coordinate purge audit pruning
79ebceba18  fix: chain the pruning migrations after the merged reason column
a24a1313fe  fix: keep reason transitions and resolve timestamp ties when pruning

Three of those are explicitly review-driven fixes to the deletion predicate.

831b571c85 shares both its subject (fix(deletion-retention): coordinate purge audit pruning) and its author date with on-branch 4592c4217b, so it is that commit's pre-rebase counterpart. The approval therefore covered a tree equivalent to the branch through 4592c4217b, and the two commits provably never seen are 79ebceba18 and a24a1313fe — the latter, the timestamp-tie fix, landing a full week later on 2026-09-01.

This repo does not appear to dismiss stale reviews on push. So three things are true at once: the badge says APPROVED, CI is green, and the tree that was approved no longer exists on the branch. Every surface a reviewer glances at says "approved and clean", while the thing that was approved is gone. A merge on that approval would ship code rusackas never saw, on a feature that irreversibly deletes rows from an audit log.

The concrete ask: a maintainer should dismiss the stale approval, so the badge stops asserting coverage that does not exist — and 79ebceba18 + a24a1313fe should get a fresh look at head. For scope, this PR's own delta is 15 files, +2349/−27; that is what needs re-reviewing.

Right now the only thing standing between that stale badge and a merge is that the branch is mergeable: false / mergeable_state: "dirty" — it is conflicting and cannot land until rebased. Please don't rely on that. Whoever merges this should get a fresh approval at head. The re-review request in the 2026-09-01 comment is the right instinct; the badge just doesn't reflect it.

Everything below is a genuine review of the code at head, not a rubber stamp of that badge.


The deletion predicate: I worked it hard and did not find an over-deletion path

This is the part that matters. Scheduled, automated, irreversible deletion from the record you consult precisely when something has gone wrong. I traced every path in prune_audit.py end to end and tried to construct a row that should survive but doesn't. I could not. Specifics, so you can check my work rather than take my word:

Candidate selection is inside the DELETE, not paged from a snapshot. _delete_statement (superset/commands/deletion_retention/prune_audit.py:513-520) embeds the candidate SELECT as a derived table, so every survivor/boundary/status/age predicate is evaluated against the same committed state as the mutation. No stale id list crosses a transaction boundary. This is the right shape and it structurally kills the whole class of "selected as duplicate, promoted to survivor before the delete lands" races.

Ties resolve on the preserving side, and the ordering question turns out to be a non-issue. .order_by(table.c.created_on) (:410, :466, :508) is indeed not a total order — but it isn't choosing a survivor. Survivorship is decided entirely by the set predicate _repeats_an_earlier_block (:288-347), which uses strict earlier.created_on < table.created_on, so tied same-reason blocks are not "earlier" than each other and all of them are retained. ORDER BY only picks which of several equally-deletable rows go in this batch. Adding id as a tiebreaker would make batching deterministic, but it would not change which rows survive.

Reason transitions survive, including the tied case. The inclusive >= / <= bounds on reason_change (:325-326) are correct and, as the comment says, can only add boundaries — i.e. only ever preserve more. I hand-walked A,A,B,B,A: the second A and second B prune; the run heads A@t1, B@t3, and the returning A@t5 all survive. I also checked the case that worried me most — whether deleting a duplicate can destroy the only distinct-reason witness that protects a later run head. It cannot: a duplicate's own run head is necessarily inside the same witness interval (if it weren't, an intervening distinct-reason row would have disqualified the duplicate in the first place), so the witness survives the delete. The predicate is stable under its own deletions.

All three previously-broken invariants are genuinely closed at head — verified in code, not from commit subjects:

Invariant Where it is enforced Verified
Survivor invariant (boundary never recedes) _evidence_candidates bounds_surviving_blocks, :490-501 — refuses to delete evidence while an older blocked/pending row exists for the entity
Promotion path _in_current_streak :274-285 is strict (>), so a row tied with a boundary sits on the boundary's resolved side and cannot mint a new exempt survivor
Unstable-block guard on age-based expiry _operational_candidates :445-454unstable_block applies _preceded_by_unresolved_attempt to the age category, not just the duplicate category

The clock/lock ordering is right, and this is the subtle part I want to call out as correct. write_ahead acquires the coordination lock at superset/commands/deletion_retention/audit.py:148 and only then calls utc_now() at :156. That ordering is what actually makes the invariant hold: a writer that loses the race to a prune batch gets a timestamp after that batch committed, so it cannot materialize in pruning's already-processed logical past. _recover_retention_blocked does the same (:369 then :394). If those two lines were ever reordered the whole guarantee collapses — worth a comment saying so, because it reads like incidental statement order.

finalize() / finalize_retention_blocked() deliberately do not take the lock, and that's fine — they mutate in place and never assign a new timestamp, and any blocked row after a pending row is already protected by _preceded_by_unresolved_attempt (:350-378, with <= so ties defer). Because the DELETE is a single statement, the boundary subquery and the pending guard always see the same snapshot, so there is no window where one sees "pending" and the other sees "confirmed".

Other gates I re-derived at head: alembic is single-headed (c7f53d184ea2a6c21e5b4d9339097d124752, 383 revisions, exactly one head — the re-chain in 79ebceba18 is correct); pruning is disabled by default (superset/config.py:1034, and the task at superset/tasks/deletion_retention.py requires enabled is not True to bail, so 1/"true" fail closed); each batch is a single statement plus commit, so no partially-applied DELETE; CI is 61 success / 9 skipped / 3 neutral with zero failures; 7 review threads, 0 unresolved.

Test coverage pins the invariants, not just the happy path. tests/integration_tests/deletion_retention/prune_audit_tests.py has named tests for each: test_delete_rechecks_survivor_after_a_pending_attempt_appears, test_evidence_expiry_spares_a_boundary_that_still_bounds_blocked_rows, test_age_does_not_make_an_unstable_block_expirable, plus four dedicated tie tests (test_a_tied_reason_change_still_breaks_the_run, test_a_block_tied_with_an_unresolved_attempt_is_deferred, test_evidence_expiry_spares_a_boundary_tied_with_a_blocked_row, test_tied_same_reason_blocks_are_all_retained). That is the right shape for a change like this.


Findings

1. A prune batch holds the global audit lock across the DELETE, and a slow batch makes concurrent scheduled purges fail closed

_delete_batch (prune_audit.py:531-534) takes acquire_coordination_lock(db.session) and holds it through a DELETE whose predicate contains three correlated EXISTS subqueries plus a GROUP BY boundary subquery over purge_audit_log. Meanwhile write_ahead (audit.py:148) needs the same singleton row before every write-ahead audit insert.

If a batch runs long on a large table — exactly the deployment this feature targets — a concurrent scheduled purge blocks on that row and can hit the metadata DB's lock-wait timeout. write_ahead's except Exception swallows it and returns None, and superset/tasks/deletion_retention.py:244-250 then raises to skip the purge:

if record_id is None:
    # Fail closed: the scheduled purge must not delete unauditably.

So the failure mode is correct (fail closed, entity retried next run) rather than silent audit loss — good. But the outcome is that a heavy prune can cause purge skips, and the operator sees only a warning from write_ahead with no attribution to pruning. Two things would help: (a) 03:30 vs 00:00 gives 3.5h of separation but nothing enforces it, so please say in UPDATING.md that the two schedules should not be collapsed; (b) consider distinguishing PurgeAuditCoordinationError from a generic audit-write failure in that log line, so "your purge was skipped because pruning held the lock" is diagnosable. Is holding the lock across the DELETE actually required, given that candidate re-evaluation inside the statement already handles the concurrency case?

2. _evidence_candidates fails open on a NULL entity_uuid, where the other two categories fail closed

entity_uuid is Column(String(36), nullable=True) (superset/models/purge_audit_log.py), and entity_uuid() in purge_cascade.py:99-102 returns None for any model without a uuid attribute — so NULL-uuid confirmed / target_absent rows are reachable.

The other two categories handle that explicitly: _duplicate_candidates filters entity_uuid.is_not(None) (:405), and _operational_candidates marks NULL-uuid blocked rows unstable_block with the comment "No identity, so no streak to be proven redundant against" (:449-450). _evidence_candidates has neither. Its guard correlates on older.c.entity_uuid == table.c.entity_uuid (:497), which is NULL-comparison-false, so the EXISTS is always false and the row is unconditionally deletable once past the window.

This is not a survivor-invariant hole — a NULL-uuid row is excluded from _streak_boundary_subquery (:256) and so was never a boundary, meaning nothing gets promoted. But it is an inconsistency in the module's own stated fail-closed posture, applied to the most protected status class. Un-attributed destruction evidence expires while attributed evidence for a comparable entity is held back by its guard. Either add the same entity_uuid.is_not(None) fail-closed treatment, or document explicitly why evidence is the one category where a missing identity licenses deletion.

3. No dry-run mode on an irreversible deleter

SOFT_DELETE_PURGE_DRY_RUN exists at superset/config.py:1026 for the purge task, and the config comment for PURGE_AUDIT_PRUNING_ENABLED (:1029-1034) says the switch is opt-in "so operators can validate retention policy and workload characteristics before the first irreversible run". But the only two states are off and deleting for real — there is no way to actually validate that policy against production data first.

Given the category counts already flow through PruneRunResult and out to metrics, a PURGE_AUDIT_PRUNE_DRY_RUN that counts candidates per category and skips the DELETE would be a small addition, and it is the difference between an operator being able to answer "what would this remove from my audit log?" and having to find out by removing it. Strongly recommended for a feature in this blast radius.

4. _has_candidates leaves an open read transaction that run_prune never closes

_has_candidates (prune_audit.py:537-539) executes a SELECT on db.session with no commit or rollback, and run_prune (:642-654) returns straight out of the category loop. On the drained-early path the last _delete_batch commits and the session is clean, but on the budget-exhausted path the run ends with an open transaction that survives until the task tears the session down — an idle-in-transaction connection on the metadata DB after every carried-over run. A db.session.rollback() after the read, or once at the end of run_prune, closes it.

5. Recovery now rewrites the block's timestamp — intentional, but it should be documented

_recover_retention_blocked changed created_on=snapshot.created_on to created_on=utc_now() (audit.py:391-394). The comment explains why (it must not land in pruning's logical past), and I agree that's necessary. But the consequence is that on the crash-recovery path the audit row no longer records when the block actually occurred — it records when recovery noticed. For a table whose whole purpose is "blocked since", that is a real fidelity change and it is not mentioned in UPDATING.md. Worth a line there, or a recovered_from marker so the discontinuity is visible in the data rather than only in the source.

6. Batch constants are not operator-tunable

BATCH_SIZE = 500 and MAX_BATCHES_PER_RUN = 10 (:143, :146) cap a run at 5,000 rows. The retention windows are config; the throughput that has to keep up with them is not. A deployment generating more than 5,000 prunable rows/day never converges, and the only signal is the carried_over gauge staying at 1 forever with no lever to pull. The starvation-avoidance reservation logic in run_prune (:642-652) is careful and correct — I checked it can't overrun the budget — which makes it more of a shame that the budget itself is fixed. (Bito raised this too; I think it's right.)

7. create_index is non-concurrent — expected, but call it out for operators

a6c21e5b4d93 builds a four-column index on purge_audit_log via create_index (2026-08-24_15-50_a6c21e5b4d93_index_purge_audit_pruning.py:37-41), which wraps plain op.create_index — no CONCURRENTLY. On PostgreSQL that takes a lock blocking writes to purge_audit_log for the build. This matches the repo's shared-utils convention so I'm not asking you to change it, but this table is large by hypothesis (that's the premise of the PR), and UPDATING.md currently says nothing about migration duration. A one-line heads-up would be kind.

The index itself is well chosen — (status, entity_type, entity_uuid, created_on) matches the boundary GROUP BY, _repeats_an_earlier_block, _preceded_by_unresolved_attempt, and bounds_surviving_blocks. Both downgrade()s are clean and non-data-destructive with respect to purge_audit_log; c7f53d184ea2's drops only the coordination table, which is derived state.


Also good

UPDATING.md is unusually honest for a change like this — it names the beat entry, the default-off switch, the "never touched unless you opt in" evidence rule, and the CELERY_CONFIG-override footgun. The startup warnings in initialization/__init__.py are correctly gated on PURGE_AUDIT_PRUNING_ENABLED and are decoupled from SOFT_DELETE, which is right — audit rows outlive the flag. The non-boolean warning added there is a good catch.

Method / caveats

Everything above is INSPECTED, not RAN. This environment cannot import superset_core, so pytest fails at tests/conftest.py before collection; that is an environment gap on my side, not a defect in this PR. I confirmed statically that the new tests import prune_audit directly, so reverting the production module would fail them at import — but I did not execute them, and I am not claiming otherwise. All file:line citations were verified against a24a1313fe.

Nothing here is a blocker on the deletion logic — the predicate held up to everything I threw at it, which for +669 lines of audit-deleting SQL is genuinely impressive work. The one thing I would not merge without is a fresh approval at head.

@github-actions github-actions Bot added the requires:rebase Requires rebasing on top of current master label Sep 3, 2026
mikebridge pushed a commit to mikebridge/superset that referenced this pull request Sep 4, 2026
…p ties when pruning

Address the review threads on apache#43490:

* A blockage streak's survivors are now the first row of each run of
  consecutive same-reason blocked rows — the streak's earliest row and the
  first row after every change of block reason — instead of the earliest
  row only. This is the audit writer's own suppression rule
  (finalize_retention_blocked) applied retroactively; previously a
  `report_schedule` block followed by a `cascade_integrity_failure` block
  lost the only durable evidence of the latter on the next prune. Run
  heads are exempt from age-out like the earliest row was; resolved-streak
  run heads still age out. The reason comparison is NULL-safe on every
  supported dialect so pre-feature rows form a run of their own that the
  first coded block ends, exactly as at write time.

* Timestamp ties (legacy second-precision rows, two writers in one clock
  tick) are resolved on the preserving side, consistently: a pending row
  tied with a blocked row counts as preceding it (the block is deferred
  until the attempt resolves); a blocked row tied with a boundary sits on
  the boundary's resolved side (it ages out rather than seeding a new
  current streak) and the evidence guard now keeps the boundary until that
  tied row is gone; tied same-reason blocked rows are all retained.

* Startup diagnostics warn when PURGE_AUDIT_PRUNING_ENABLED is not a
  boolean. The task already failed closed on such values, but the
  initializer collapsed them into "disabled" and emitted nothing, so a
  typo like "true" left the audit log growing without any warning.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01267VBWbvWTNZUg9GvXKgkC
@mikebridge
mikebridge force-pushed the sc-116701-purge-audit-pruning branch from a24a131 to 5acd469 Compare September 4, 2026 00:15
@mikebridge

Copy link
Copy Markdown
Contributor Author

Rebased onto latest master (the branch was 225 commits behind) and force-pushed to 5acd469b2a.

What changed in the rebase:

  • UPDATING.md conflict resolved keeping both entries — master's ### Archived dataset purge requires impact confirmation (from feat(dataset): warn about dependent charts and dashboards before purging an archived dataset #43724) and this PR's - The purge audit log can now be pruned automatically… bullet now coexist under ## Next.
  • Migration chain re-pointed to keep a single alembic head. Master advanced 39097d124752 with 8f31c5d726ab (index_dataset_dependency_lookups), which would have left this PR's a6c21e5b4d93 as a second head off 39097d124752. a6c21e5b4d93 is now chained onto 8f31c5d726ab, so c7f53d184ea2 is the sole head. The enforce-single-migration-head pre-push gate passes.
  • No code changes beyond the conflict resolution and the down-revision re-point; changed-file pre-commit is clean (mypy / ruff / pylint all green).

@rusackas — heads-up that the existing approval predates both the earlier delta-review fix (the differing-reason timestamp-tie that could delete a reason-transition run-head, fixed with inclusive tie bounds + a control-run test) and this rebase, so GitHub is still showing the old green badge. Could you take a fresh look before merge rather than merging on the stale approval? Thanks.

mikebridge pushed a commit to mikebridge/superset that referenced this pull request Sep 8, 2026
…eoff

Addresses Amin Finding 1 on apache#43490: the pruning batch holds the coordination
lock across its bulk DELETE, so a concurrent scheduled purge's write_ahead can
block on it up to the metadata DB lock-wait timeout (then fails closed and
retries next cycle).

The lock scope is deliberate and load-bearing: it is the same singleton lock
write_ahead takes before stamping created_on, so holding it across the DELETE
guarantees any row committed after a batch is timestamped after that batch's
cutoff and cannot materialize inside a streak it already pruned. Narrowing the
lock to a timestamp-only prelude would reintroduce that race. The window is
bounded instead (BATCH_SIZE=500 + the ix_purge_audit_log_pruning index), so no
logic changes here -- only the missing documentation:

- write_ahead: comment the lock-then-clock ordering Amin flagged as reading
  like incidental statement order.
- _delete_batch: comment why the lock spans the DELETE plus the bounded
  liveness tradeoff.
- UPDATING.md: spell out the operator-visible tradeoff (skipped-and-retried
  purge cycle, run pruning off-peak).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01267VBWbvWTNZUg9GvXKgkC
mikebridge pushed a commit to mikebridge/superset that referenced this pull request Sep 8, 2026
sadpandajoe's over-deletion finding #3 on apache#43490 is real: a `force`-trigger
blocked row following a same-reason `scheduled` block in the same streak was
pruned as a duplicate, erasing the force-purge audit record. Confirmed by
integration test against head e2dddd9 (the force row was deleted).

`_repeats_an_earlier_block` now excludes force-trigger rows: the pruner mirrors
the writer, whose `_suppress_redundant_block` only collapses consecutive
scheduled same-reason blocks. Both consumers benefit — the duplicate category
skips force rows, and the operational category marks them survivors while their
streak is current. A force row may still be the *earlier* anchor a later
scheduled repeat collapses into, so legitimate pruning is unaffected.

Scope, stated precisely (this protects, it is not blanket immortality): a force
block is exempt from duplicate collapse and from current-streak age-out. Once
its streak resolves, a force block ages on the operational window like any
resolved-streak blocked row — the confirmed/target_absent boundary is the
durable evidence. A characterization test pins that boundary for the committer
to sign off on.

Findings #1 (pending tied to the later block at MySQL second granularity) and #2
(reason-transition block amid repeats) were verified COVERED by the existing
`<=` unresolved-attempt tie and the reason-change discrimination; reverted-fix
controls confirmed both guards are load-bearing. Added integration tests for all
three scenarios, a control proving the force exemption still collapses a later
scheduled repeat, and the resolved-streak scope-boundary test.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01267VBWbvWTNZUg9GvXKgkC
@mikebridge
mikebridge force-pushed the sc-116701-purge-audit-pruning branch from c9173e8 to 5706b96 Compare September 8, 2026 15:56
@github-actions github-actions Bot removed the requires:rebase Requires rebasing on top of current master label Sep 8, 2026
@aminghadersohi
aminghadersohi self-requested a review September 8, 2026 16:41
@bito-code-review

bito-code-review Bot commented Sep 9, 2026

Copy link
Copy Markdown
Contributor

Code Review Agent Run #37eaea

Actionable Suggestions - 0
Additional Suggestions - 7
  • superset/migrations/versions/2026-08-24_16-20_c7f53d184ea2_coordinate_purge_audit_pruning.py - 1
    • Non-idempotent seed insert · Line 52-55
      `create_table` is idempotent (skips if the table exists), but the seed via `op.bulk_insert` is not. If the migration is re-run after a partial failure, or the table pre-exists with the sentinel already present, the insert raises a duplicate primary-key error and blocks `db upgrade`. Guard the seed against the sentinel id, matching the idempotency of `create_table`.
  • superset/models/purge_audit_log.py - 1
    • Misleading docstring, status duplication · Line 48-60
      The docstring says the retention partition is "asserted against this constant rather than restated", but `prune_audit.py` defines `OPERATIONAL_STATUSES`/`PROTECTED_STATUSES` independently and never references `ALL_STATUSES`; only the unit test asserts the partition. The statuses are duplicated across the model and `prune_audit.py`, so a new status added here without a retention category would silently never be pruned in production. Consider asserting the partition against `ALL_STATUSES` in `prune_audit.py` and correcting the docstring.
  • tests/integration_tests/deletion_retention/audit_tests.py - 1
    • Duplicate tests, misleading docstrings · Line 125-131
      `test_pruner_waits_for_uncommitted_writer` and `test_writer_waits_for_uncommitted_pruner` are functionally identical: `first_role`/`waiting_role` only feed the thread name, and `_assert_coordination_lock_serializes` only proves `acquire_coordination_lock` serializes two acquisitions. The docstrings claim distinct writer/pruner interleaving outcomes (row exposure, timestamping) the test never exercises, giving false coverage confidence. Consolidate into one test and align the docstring.
  • superset/commands/deletion_retention/audit.py - 1
    • Uncaught lock error in recovery · Line 380-380
      `_recover_retention_blocked` calls `acquire_coordination_lock` (line 380), which raises `PurgeAuditCoordinationError` (a `RuntimeError`) when the sentinel row is missing/indeterminate. The handler only catches `SQLAlchemyError`, so that error escapes to `_purge_model`, where it is mislabeled as a cascade failure and the blocked-evidence recovery is silently lost. Catch `PurgeAuditCoordinationError` too, matching the best-effort intent of this path.
  • superset/initialization/__init__.py - 1
    • Fragile dedup coupling · Line 1058-1063
      The `not soft_delete_enabled` guard suppresses the audit-prune imports warning whenever soft delete is on, relying on the purge imports warning (lines 1035-1047) to cover the same missing module. That coupling is correct today but fragile: if the purge warning's gating ever changes, the audit-prune NotRegistered case becomes silent. Consider a brief comment documenting the dependency.
  • tests/unit_tests/commands/deletion_retention/test_prune_audit.py - 1
    • Weak sqlite null-safe assertion · Line 296-296
      The sqlite assertion `" IS " in sql` is too loose: both `_duplicate_candidates` and `_operational_candidates` emit `entity_uuid IS NOT NULL`/`entity_uuid IS NULL` predicates that already contain ` IS `, so the sqlite branch would pass even if the null-safe reason comparison regressed to `==`. Strengthen the assertion to match the reason-comparison fragment specifically (e.g. assert `"reason IS " in sql` for the sqlite dialect) so it cannot be satisfied by the unrelated entity_uuid predicate.
  • superset/commands/deletion_retention/prune_audit.py - 1
    • Replace Any with object type · Line 166-166
      `typing.Any` is disallowed for the `value` parameter. Since the value is validated with `isinstance` and converted with `int()`, use `object` instead of `Any` to maintain type safety.
Review Details
  • Files reviewed - 14 · Commit Range: 21965c5..5706b96
    • superset/commands/deletion_retention/audit.py
    • superset/commands/deletion_retention/prune_audit.py
    • superset/config.py
    • superset/initialization/__init__.py
    • superset/migrations/versions/2026-08-24_15-50_a6c21e5b4d93_index_purge_audit_pruning.py
    • superset/migrations/versions/2026-08-24_16-20_c7f53d184ea2_coordinate_purge_audit_pruning.py
    • superset/models/purge_audit_log.py
    • superset/tasks/deletion_retention.py
    • tests/integration_tests/deletion_retention/_base.py
    • tests/integration_tests/deletion_retention/audit_tests.py
    • tests/integration_tests/deletion_retention/prune_audit_tests.py
    • tests/unit_tests/commands/deletion_retention/test_prune_audit.py
    • tests/unit_tests/initialization_test.py
    • tests/unit_tests/migrations/test_purge_audit_coordination.py
  • Files skipped - 1
    • UPDATING.md - Reason: Filter setting
  • Tools
    • MyPy (Static Code Analysis) - ✔︎ Successful
    • Astral Ruff (Static Code Analysis) - ✔︎ Successful
    • Whispers (Secret Scanner) - ✔︎ Successful
    • Detect-secrets (Secret Scanner) - ✔︎ Successful

Bito Usage Guide

Commands

Type the following command in the pull request comment and save the comment.

  • /review - Manually triggers an incremental AI Review.

  • /review full - Manually triggers a full AI Review.

  • /pause - Pauses automatic reviews on this pull request.

  • /resume - Resumes automatic reviews.

  • /resolve - Marks all Bito-posted review comments as resolved.

  • /abort - Cancels all in-progress reviews.

Refer to the documentation for additional commands.

Configuration

This repository uses Superset You can customize the agent settings here or contact your Bito workspace admin at evan@preset.io.

Documentation & Help

AI Code Review powered by Bito Logo

@github-actions github-actions Bot added the requires:rebase Requires rebasing on top of current master label Sep 9, 2026
Mike Bridge and others added 14 commits September 9, 2026 09:33
…t log

The purge audit log grew without bound: every scheduled run against the
same blocked entity wrote another identical record (until apache#42863 deduped
the write path), and nothing ever aged out operational noise. This adds
the deletion-only pruning task defined by SC-116701:

- Blocked duplicates within an entity's current blockage streak reduce
  to the streak's earliest record (blocked-since), regardless of age;
  the survivor is never deleted while the streak is current. Streak
  ordering rides apache#42863's microsecond precision and predecessor index —
  no schema change.
- Operational records (failed, resolved-streak blocked) age out past
  PURGE_AUDIT_RETENTION_DAYS (default 90, validated fail-closed:
  invalid values skip the category, never widen removal).
- Completed-destruction evidence (confirmed, target_absent) is never
  touched unless the separate PURGE_AUDIT_EVIDENCE_RETENTION_DAYS
  opt-in is explicitly set — the operator's compliance assertion.
  Pending rows and future-dated rows are never candidates.

One shared budget per run (10 batches x 500 rows, priority order:
duplicates, operational, evidence) bounds every run and converges
backlogs across runs; conditional per-batch deletes counted from
rowcounts make overlapping runs safe without a lock. The Celery task
(deletion_retention.prune_purge_audit, default beat entry daily 03:30)
reports per-category counts through deletion_retention.prune_audit.*
metrics and one structured log line; a disabled run reports itself. The
retention startup warning now also covers the new beat entry.

Tests: 25 unit (classification, fail-closed validation, task
reporting/isolation, clock parity with the audit writer) + 11
integration (age-independent dedup, bounded convergence with carryover,
cross-entity isolation, resolved streaks, evidence protection by
count-and-identity, opt-in expiry, pending/future immunity, rerun
idempotence). Spec chain in specs/sc-116701-purge-audit-pruning/.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A nine-lens review panel found one HIGH and several MEDIUM defects in the
pruning policy. All of them centred on the same thing: the "blocked since"
survivor was protected at candidate-selection time but not at deletion
time, so anything that moved a streak boundary could destroy the one row
the feature exists to keep.

- A streak boundary is no longer removed while it still bounds something.
  Evidence expiry refuses to delete a confirmed/target_absent row while a
  blocked row older than it survives for the same entity. Without this a
  boundary could recede (deleted by an overlapping run, or by an evidence
  window shorter than the operational one) and promote already-selected
  duplicates into a current streak, whose new survivor the in-flight run
  would then delete. Because boundaries can now only move forward, a
  selected duplicate can never become a survivor before it is deleted --
  which is what makes the invariant hold across overlapping runs and
  concurrent reconcile_pending without a distributed lock.
- A failed attempt no longer ends a blockage streak. A failed purge is an
  infrastructure outcome, not evidence the blockage cleared; treating it
  as a boundary let one transient error demote the blocked-since survivor
  and restate the blockage as beginning after the failure.
- Blocked rows with no entity_uuid belong to no streak and so can never be
  proven redundant; they are kept rather than aged out (fail closed).
- Future-dated rows are excluded from streak classification, not just from
  candidacy, so a skewed writer clock cannot resolve a live streak.
- Config resolution returns a ResolvedWindow that distinguishes "off" from
  "misconfigured", instead of a bare None the caller had to re-read config
  to interpret. PURGE_AUDIT_RETENTION_DAYS is renamed
  PURGE_AUDIT_OPERATIONAL_RETENTION_DAYS -- it never governed evidence, and
  the name is free to fix before release.
- The metric leaf matches the task name, carried_over is a metric (backlog
  non-convergence was previously log-only), and the failure path rolls the
  session back.
- Tidying: statuses partition an ALL_STATUSES set exported by the model so
  an uncategorised new status fails a test; one shared utc_now with the
  audit writer; table-driven category drain; precise types in place of Any.

Tests: +8 (boundary guard including the inverted-window case, failed
streak-transparency, uuid-less block retention, skew-resistant
classification, status-change-between-select-and-delete, exhaustiveness
contract). Unit 102, integration 89, pre-commit and mypy clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…eview found

The previous commit claimed the survivor invariant held by construction
because streak boundaries could only move forward. That proof was wrong:
moving a boundary forward demotes the current survivor, which *promotes*
the next blocked row into the survivor slot — and that row may already be
in the delete batch. A re-review reached the original harm through it.

The reachable interleaving needs no overlapping prune runs. An entity is
blocked, a later purge attempt writes a pending row and stalls, a third
evaluation records another block. Pruning classifies the newest block as a
duplicate (pending is not a boundary), and before the delete lands the
stalled attempt is finalized in place — by the worker or by
reconcile_pending — keeping its original timestamp. The boundary appears
mid-history, the newest block becomes the new streak's survivor, and the
conditional delete still matches it because its status never changed.

The asymmetry is that a pending row is the only thing that can insert a
boundary into history: every other write lands at `now`, newer than
everything. So blocked rows preceded by an unresolved attempt are no
longer eligible for deduplication until it resolves — their
classification is not stable. Pending rows are transient, so the deferred
rows are collected by a later run. The evidence boundary guard counts
older pending rows too, since the purge path can finalize one to blocked.

Also from the same pass:

- Each category is guaranteed at least one batch of the shared budget.
  Strict priority let a permanent duplicate backlog starve the age-based
  categories forever, reintroducing the unbounded growth this feature
  exists to stop.
- carried_over no longer reports a backlog when the budget ran out but
  nothing remained; it is the only alarm for non-convergence, so a false
  positive blunts it.
- The module docstring's safety argument is corrected — it was the
  written justification for shipping without a lock, so leaving it wrong
  would have kept the hole open.
- The ascending-order comment no longer claims a guarantee that the
  boundary guard actually provides.

Tests: +3, including a red-first control (removing the guard fails
test_a_block_after_an_unresolved_attempt_is_not_treated_as_a_duplicate
and nothing else). Unit 102, integration 92, pre-commit and mypy clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…expiry

A third adversarial pass found the previous fix incomplete. The guard that
defers blocked rows preceded by an unresolved attempt was applied only to
the duplicate category, so the operational category deleted the very rows
the duplicate category had just deferred -- in the same run, with no
concurrency involved at all.

Reproduced deterministically: an entity blocked at 100d, a purge attempt
that stalls at 98d, another block recorded at 95d. Deduplication defers
the 95d row (correct), age-based expiry then removes it because it is past
the 90-day window and is not the current survivor. When the stalled
attempt finalizes in place, the boundary appears between the two blocks,
and the row that should have become the new streak's survivor is already
gone. The remaining history then asserts the object was destroyed at 98d
with no trace it was still blocked at 95d -- an affirmatively false
record, not merely a lost one.

Age never made that classification stable: an old blocked row inside a
live streak is exactly the "blocked for years" case FR-009 exists to
protect. Both categories now share the guard. Also caps the per-category
allowance at the remaining budget, so the anti-starvation floor cannot
overspend a budget set below the category count.

The module docstring no longer claims every write lands at `now`. It does
not: `created_on` is stamped by the writing process, not the database, and
_recover_retention_blocked re-inserts a row at its original timestamp. The
guards are select-time, so a row becoming visible with a backdated
timestamp between this module's SELECT and DELETE would evade them. That
residual race is now documented precisely, with the three ways to close it
properly (candidacy inside the DELETE, serializing against audit writers,
or a database-assigned created_on), rather than papered over -- the
previous docstring's false safety proof is what let this hole persist
through two remediations.

Tests: +1 with a red-first control (removing the operational guard fails
test_age_does_not_make_an_unstable_block_expirable and nothing else).
Unit 102, integration 93, pre-commit and mypy clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…d reason column

apache#43485 (purge block reason codes) merged to master as 88d2c29, adding
1072de5ed955 -> 39097d124752. This branch's migrations still pointed at
1072de5ed955, so both chains forked from the same parent and alembic saw
two heads -- failing enforce-single-migration-head and, because
`db upgrade` then refuses to run, every database-backed CI suite with it.

Re-points a6c21e5b4d93 (the pruning index) at 39097d124752 so the chain
is linear:

  1072de5ed955 -> 39097d124752 -> a6c21e5b4d93 -> c7f53d184ea2

No migration content changes -- only the parent pointer and its matching
docstring header. The two PRs were always independent (this one adds an
index and a coordination table; that one adds a column), so ordering is
the only thing that had to be resolved.

Verified: `superset db heads` reports a single head, a full `db upgrade`
from an empty database applies all three in order (exit 0), and
downgrade back to 39097d124752 followed by a re-upgrade round-trips
cleanly.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…p ties when pruning

Address the review threads on apache#43490:

* A blockage streak's survivors are now the first row of each run of
  consecutive same-reason blocked rows — the streak's earliest row and the
  first row after every change of block reason — instead of the earliest
  row only. This is the audit writer's own suppression rule
  (finalize_retention_blocked) applied retroactively; previously a
  `report_schedule` block followed by a `cascade_integrity_failure` block
  lost the only durable evidence of the latter on the next prune. Run
  heads are exempt from age-out like the earliest row was; resolved-streak
  run heads still age out. The reason comparison is NULL-safe on every
  supported dialect so pre-feature rows form a run of their own that the
  first coded block ends, exactly as at write time.

* Timestamp ties (legacy second-precision rows, two writers in one clock
  tick) are resolved on the preserving side, consistently: a pending row
  tied with a blocked row counts as preceding it (the block is deferred
  until the attempt resolves); a blocked row tied with a boundary sits on
  the boundary's resolved side (it ages out rather than seeding a new
  current streak) and the evidence guard now keeps the boundary until that
  tied row is gone; tied same-reason blocked rows are all retained.

* Startup diagnostics warn when PURGE_AUDIT_PRUNING_ENABLED is not a
  boolean. The task already failed closed on such values, but the
  initializer collapsed them into "disabled" and emitted nothing, so a
  typo like "true" left the audit log growing without any warning.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01267VBWbvWTNZUg9GvXKgkC
Master advanced 39097d124752 with 8f31c5d726ab (index_dataset_dependency_lookups).
Re-point a6c21e5b4d93 onto 8f31c5d726ab so the pruning migrations form a single
alembic head (c7f53d184ea2) instead of branching a second head off 39097d124752.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01267VBWbvWTNZUg9GvXKgkC
Replace 'is now a resolved-streak row' with 'has become a resolved-streak row'
to satisfy the timeless-comment rule (CLAUDE.md) and the pre-push tripwire.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01267VBWbvWTNZUg9GvXKgkC
…eoff

Addresses Amin Finding 1 on apache#43490: the pruning batch holds the coordination
lock across its bulk DELETE, so a concurrent scheduled purge's write_ahead can
block on it up to the metadata DB lock-wait timeout (then fails closed and
retries next cycle).

The lock scope is deliberate and load-bearing: it is the same singleton lock
write_ahead takes before stamping created_on, so holding it across the DELETE
guarantees any row committed after a batch is timestamped after that batch's
cutoff and cannot materialize inside a streak it already pruned. Narrowing the
lock to a timestamp-only prelude would reintroduce that race. The window is
bounded instead (BATCH_SIZE=500 + the ix_purge_audit_log_pruning index), so no
logic changes here -- only the missing documentation:

- write_ahead: comment the lock-then-clock ordering Amin flagged as reading
  like incidental statement order.
- _delete_batch: comment why the lock spans the DELETE plus the bounded
  liveness tradeoff.
- UPDATING.md: spell out the operator-visible tradeoff (skipped-and-retried
  purge cycle, run pruning off-peak).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01267VBWbvWTNZUg9GvXKgkC
sadpandajoe's over-deletion finding #3 on apache#43490 is real: a `force`-trigger
blocked row following a same-reason `scheduled` block in the same streak was
pruned as a duplicate, erasing the force-purge audit record. Confirmed by
integration test against head e2dddd9 (the force row was deleted).

`_repeats_an_earlier_block` now excludes force-trigger rows: the pruner mirrors
the writer, whose `_suppress_redundant_block` only collapses consecutive
scheduled same-reason blocks. Both consumers benefit — the duplicate category
skips force rows, and the operational category marks them survivors while their
streak is current. A force row may still be the *earlier* anchor a later
scheduled repeat collapses into, so legitimate pruning is unaffected.

Scope, stated precisely (this protects, it is not blanket immortality): a force
block is exempt from duplicate collapse and from current-streak age-out. Once
its streak resolves, a force block ages on the operational window like any
resolved-streak blocked row — the confirmed/target_absent boundary is the
durable evidence. A characterization test pins that boundary for the committer
to sign off on.

Findings #1 (pending tied to the later block at MySQL second granularity) and #2
(reason-transition block amid repeats) were verified COVERED by the existing
`<=` unresolved-attempt tie and the reason-change discrimination; reverted-fix
controls confirmed both guards are load-bearing. Added integration tests for all
three scenarios, a control proving the force exemption still collapses a later
scheduled repeat, and the resolved-streak scope-boundary test.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01267VBWbvWTNZUg9GvXKgkC
A sibling migration (7e2c9a4f1b83 create_task_dependencies_table) merged onto
the shared parent b3e9c1a75d24, so pointing the pruning chain at b3e9c1a75d24
produced two alembic heads. Re-point the first pruning migration
(a6c21e5b4d93) onto the current master tip 7e2c9a4f1b83 so the chain is
single-headed again.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01267VBWbvWTNZUg9GvXKgkC
The operator guide still stated the purge_audit_log is "never pruned by
design" and must be pruned manually, directly contradicting the automatic
pruning this PR introduces (sadpandajoe review, 2026-09-05). Replace that
sentence with an accurate description of the new policy: automatic pruning via
the deletion_retention.prune_purge_audit beat task, the "blocked since" record
and completed-destruction evidence preserved, only operational noise aged out
past PURGE_AUDIT_RETENTION_DAYS, and PURGE_AUDIT_PRUNING_ENABLED = False to
restore the previous never-pruned behavior.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01267VBWbvWTNZUg9GvXKgkC
…l window

Per Evan's review call on the pruning survivor exemption: go with full
immortality for operator force-purge blocks. The force exemption already kept a
force block out of duplicate collapse and marked it a current-streak survivor,
but a resolved-streak force block still aged out on the operational window. Extend
the exemption so a force block is never pruned by either category — it is retained
permanently, not just while its streak is current.

`_operational_candidates` now excludes `status == BLOCKED AND trigger == FORCE`
(failed rows, force-triggered or not, age normally). Updated the
`_repeats_an_earlier_block` / `_operational_candidates` docstrings that previously
said a resolved-streak force block ages out. Inverted the characterization test
`test_resolved_streak_force_block_ages_on_operational_window` into
`test_force_block_survives_operational_window_permanently`, pinning that a
100-day-old resolved-streak force block survives a 90-day operational prune.

A longer-but-not-forever cleanup pass for these is a possible follow-up,
deliberately out of scope (Evan's note).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01267VBWbvWTNZUg9GvXKgkC
@mikebridge
mikebridge force-pushed the sc-116701-purge-audit-pruning branch from 5706b96 to bcfa505 Compare September 9, 2026 16:00
@github-actions github-actions Bot removed the requires:rebase Requires rebasing on top of current master label Sep 9, 2026
@bito-code-review

Copy link
Copy Markdown
Contributor

AI Code Review is in progress (usually takes 3 to 15 minutes unless it's a very large PR).

Bito Usage Guide

Commands

Type the following command in the pull request comment and save the comment.

  • /review - Manually triggers an incremental AI Review.

  • /review full - Manually triggers a full AI Review.

  • /pause - Pauses automatic reviews on this pull request.

  • /resume - Resumes automatic reviews.

  • /resolve - Marks all Bito-posted review comments as resolved.

  • /abort - Cancels all in-progress reviews.

Refer to the documentation for additional commands.

Configuration

This repository uses Superset You can customize the agent settings here or contact your Bito workspace admin at evan@preset.io.

Documentation & Help

.where(_repeats_an_earlier_block(table, boundary))
.where(sa.not_(_preceded_by_unresolved_attempt(table)))
.order_by(table.c.created_on)
.limit(limit)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This limits deletions to 500, but it does not bound the work done while holding the singleton audit lock: once no duplicates remain, the age-unbounded candidate query and its boundary/EXISTS checks must examine all matching audit rows before returning zero. On the large tables this feature targets, that can hold the lock long enough for concurrent write_ahead() calls to time out and skip purge cycles; could we bound candidate discovery or provide representative query-plan/runtime evidence instead of treating BATCH_SIZE as a lock-time bound?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed — the 500 cap bounds deletions, not discovery, so the terminal (and, when duplicates are sparse, the first) batch scans O(blocked rows) under the coordination lock. Proposing the fix before I push it, since it changes the lock model you flagged:

Split discovery from the locked mutation. Run the candidate SELECT (LIMIT 500) UNLOCKED to get ≤500 candidate ids — a hint, no lock held for the scan. Then take the coordination lock and issue a DELETE scoped to those ids that re-applies the candidacy predicates as correlated WHERE clauses on the id-filtered target (not a re-embedded candidacy sub-SELECT): DELETE … WHERE id IN (:ids) AND status='blocked' AND <in_current_streak> AND <repeats_an_earlier_block> AND NOT <preceded_by_unresolved_attempt>. The planner evaluates those correlated, index-backed predicates for only the ≤500 rows, so the locked work is bounded to ~500 PK probes plus their per-entity index lookups.

This keeps the backdated-write invariant intact: the LOCKED delete re-verifies the full predicate over current committed state, so a row a concurrent write_ahead turned into a survivor between the unlocked discovery and the lock fails the re-check and is not deleted (a stale hint only ever deletes fewer rows, never wrong ones); a write_ahead arriving after the lock waits and stamps post-cutoff. The rowcount-keyed drain loop converges the remainder next run, as today.

I'll attach an EXPLAIN of the locked delete showing it's ≤500-bounded (index probes, no seq scan / full per-entity aggregate) — that doubles as the runtime evidence you asked for. Does this shape address your concern? I'll push the diff + EXPLAIN for review rather than merging first.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch — you're right that BATCH_SIZE bounded deletions but not the discovery scan, which ran inside the DELETE under the coordination lock. Fixed in ad34aee by splitting discovery from the locked mutation:

  • Discovery runs unlocked. The ≤BATCH_SIZE candidate ids are selected before the lock is taken, so the age-unbounded scan and its boundary/EXISTS checks — including the sparse/no-duplicate steady state you describe — no longer happen while holding the singleton lock.
  • Only a bounded re-check + delete run under the lock. Under the lock the batch is re-checked with a SELECT that re-applies the identical candidacy predicates over just those ≤BATCH_SIZE ids, then the survivors are deleted by literal id. Both locked statements are ≤BATCH_SIZE-scoped → per-PK and per-entity index probes, so lock-hold no longer scales with table size.
  • The boundary query is now bounded too. The streak boundary was rewritten from a global GROUP BY entity aggregate to a correlated per-entity MAX(created_on) scalar, shared by discovery and the re-check through one predicate list so the two cannot drift.
  • The re-check is a SELECT rather than a self-referencing DELETE subquery because MySQL rejects the latter (ERROR 1093); the discovery transaction is ended before the lock is taken, so the re-check reads current committed state regardless of isolation level (Superset pins MySQL to READ COMMITTED, so shipped config was already safe — this is isolation-independence hardening).

Verification: the existing behavioral run_prune integration tests pass unchanged (34/34 on Postgres), plus a new regression test for the discovery→lock re-check that forces REPEATABLE READ and was controlled red-first (reverting the pre-lock transaction reset makes it fail). Would appreciate your ack on the approach.

Comment thread UPDATING.md Outdated
This also resolves the limitation noted under *Soft delete and restore for datasets*: a database blocked by soft-deleted datasets can now be freed by purging those datasets (per-entity endpoint, retention task, or `force-purge` CLI) instead of hard-deleting `tables` rows out-of-band.

The `purge_audit_log` table is **never pruned by design** — the audit must survive the entities it names; operators who need to age it out should prune manually.
The `purge_audit_log` table is pruned automatically by the `deletion_retention.prune_purge_audit` Celery beat task (daily, 03:30), so it no longer grows unbounded and does not need manual pruning. The policy is written to preserve the audit's meaning rather than trade it away: within an entity's current blockage streak the earliest — "blocked since" — record always survives (only redundant duplicate `blocked` records are collapsed), and completed-destruction evidence (`confirmed`, `target_absent`) is **never** removed unless the separate `PURGE_AUDIT_EVIDENCE_RETENTION_DAYS` opt-in is explicitly set. What ages out is operational noise — `blocked` records from already-resolved streaks and `failed` records — once older than `PURGE_AUDIT_RETENTION_DAYS` (default 90). Set `PURGE_AUDIT_PRUNING_ENABLED = False` to restore the previous never-pruned, unbounded-growth behavior. See the release-note entry above for the beat-schedule and `CELERY_CONFIG` details.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This can leave operators with pruning disabled while believing the table is bounded: the shipped default is False, and PURGE_AUDIT_RETENTION_DAYS is not read anywhere (the key is PURGE_AUDIT_OPERATIONAL_RETENTION_DAYS). Could this state that pruning must be enabled explicitly and use the actual key?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in f933a93. You are right on both counts: PURGE_AUDIT_RETENTION_DAYS is read nowhere (the key the code reads is PURGE_AUDIT_OPERATIONAL_RETENTION_DAYS, prune_audit.py:149 / config.py:1049), and pruning ships off (PURGE_AUDIT_PRUNING_ENABLED = False, config.py:1040). Rewrote this UPDATING.md entry to say pruning is off by default and must be enabled explicitly with PURGE_AUDIT_PRUNING_ENABLED = True, and corrected the retention key — so it no longer reads as bounded-by-default. (The release-note entry higher in the file was already correct; this detailed paragraph was the one that drifted.)

Mike Bridge and others added 2 commits September 10, 2026 04:36
…t in UPDATING.md

sadpandajoe review on apache#43490: the detailed UPDATING.md entry cited a config key
that is never read (`PURGE_AUDIT_RETENTION_DAYS`) and framed pruning as on by
default ("set `PURGE_AUDIT_PRUNING_ENABLED = False` to restore ... unbounded
growth"). Both are wrong and could leave an operator believing the table is
bounded while it grows forever.

Correct it to the key the code actually reads
(`PURGE_AUDIT_OPERATIONAL_RETENTION_DAYS`, prune_audit.py:149 / config.py:1049)
and state that pruning is off by default (`PURGE_AUDIT_PRUNING_ENABLED = False`,
config.py:1040) and must be enabled explicitly. Matches the release-note entry
higher in the file, which was already correct.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01267VBWbvWTNZUg9GvXKgkC
…ked discovery + locked re-check

sadpandajoe review on apache#43490: the BATCH_SIZE cap bounds deletions but not
candidate discovery. Each `_delete_batch` embedded the full candidacy query
(boundary/repeat/pending correlated subqueries over a global GROUP-BY streak
boundary) inside the DELETE under the singleton audit coordination lock. When
duplicates are sparse/absent — the steady state — that scan is O(blocked rows)
and holds the lock long enough that concurrent `write_ahead()` calls time out
and skip purge cycles, on the large tables this targets.

Split discovery from the locked mutation:
- Discover <=BATCH_SIZE candidate ids with an UNLOCKED select — a hint.
- Under the lock, RE-CHECK the ids with a SELECT re-applying the same candidacy
  predicates, then DELETE the survivors by literal id. The re-check is a SELECT,
  not a correlated WHERE on the DELETE, because MySQL rejects a DELETE whose
  subquery reads the target table (ERROR 1093).
- End the discovery transaction (rollback; discovery is read-only) BEFORE taking
  the lock, so the re-check opens a FRESH snapshot. This makes the locked
  re-check read current committed state regardless of isolation level: under
  REPEATABLE READ, discovery's SELECT would otherwise fix the transaction's
  consistent-read snapshot and the lock's UPDATE would not refresh it, so a
  re-check on that snapshot could miss a row a concurrent `write_ahead`
  committed in the discovery->lock window. This is isolation-independence
  hardening, not a shipped-config data-loss fix: Superset pins MySQL to READ
  COMMITTED at runtime (`set_db_default_isolation`) and in CI, where every
  statement re-reads latest-committed and the stale read cannot occur. Keeping
  the re-check correct under any isolation is cheap and right; the cross-model
  pass flagged the raw-SQL-under-RR property, and the reset closes it.

To make the locked re-check bounded, the streak boundary is rewritten from a
global `GROUP BY entity` aggregate to a correlated per-entity `MAX(created_on)`
scalar (same key, `entity_uuid IS NOT NULL` and `created_on <= now` filters, and
strict-`>` tie handling), used by BOTH discovery and re-check via one shared
predicate list per category so the two cannot drift. Both locked statements are
<=BATCH_SIZE-scoped -> per-PK + per-entity index probes, so lock-hold stays short.

`_delete_batch` now returns `(discovered, removed)` and `_drain` keys "drained"
on the discovery count, not the delete rowcount, so an overlapping run that
thinned the hints cannot be misread as a fully-drained backlog.

Tests: behavioral `run_prune` integration tests unchanged (parity) — all 34 in
the file pass on Postgres. Added `test_recheck_reads_current_state_under_repeatable_read`:
it FORCES REPEATABLE READ on the session (so the guard has teeth even though the
app runs RC), injects a pending row from a separate connection in the
discovery->lock window, and asserts the later block is spared. Verified red-first
on local Postgres RR — reverting the pre-lock rollback makes it fail (later block
wrongly deleted); it runs in the Postgres lane too, so it does not depend on CI
MySQL isolation. Skipped on SQLite (no snapshot isolation). Unit tests adapted to
the new internals (the MySQL-safety test asserts the DELETE has no target-reading
subquery).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01267VBWbvWTNZUg9GvXKgkC
@bito-code-review

bito-code-review Bot commented Sep 10, 2026

Copy link
Copy Markdown
Contributor

Code Review Agent Run #0ad479

Actionable Suggestions - 0
Additional Suggestions - 5
  • superset/commands/deletion_retention/audit.py - 1
    • Unhandled lock error in recovery · Line 380-380
      `acquire_coordination_lock` raises `PurgeAuditCoordinationError` (a RuntimeError) when the sentinel row is missing, but this handler only catches `SQLAlchemyError`. The error then escapes `finalize_retention_blocked` → `_finalize_blocked` → `_purge_one` and is misreported as a cascade failure in `_purge_model`, losing the blocked audit evidence. `write_ahead` handles the same error gracefully via its broad `except Exception`; align this recovery path.
  • superset/migrations/versions/2026-08-24_16-20_c7f53d184ea2_coordinate_purge_audit_pruning.py - 1
    • Non-idempotent sentinel insert · Line 52-55
      `create_table` (shared utils) is idempotent — it skips when the table already exists — but `op.bulk_insert` here is not. On a re-run where the table and sentinel row already exist (e.g. MySQL non-transactional DDL after a partial failure), this raises a duplicate primary-key error and the migration fails. Guard the insert so it only seeds when the sentinel row is absent, matching the surrounding idempotency.
  • superset/commands/deletion_retention/prune_audit.py - 1
    • Stale snapshot on early return · Line 662-663
      The `if not ids: return 0, 0` early return exits before the `db.session.rollback()` at line 673, leaving the shared session's transaction open with a fixed MySQL/InnoDB REPEATABLE READ snapshot. In `run_prune`, the next category's discovery SELECT and `_has_candidates` then read that stale snapshot, under-draining and possibly reporting a false `drained` (suppressing `carried_over`) — the same stale-read class this PR fixes (sc-118200). Roll back before the early return.
  • tests/unit_tests/commands/deletion_retention/test_prune_audit.py - 1
    • Weak SQLite null-safety assertion · Line 337-337
      On SQLite, `is_distinct_from` renders as `IS NOT`, which already contains the substring `" IS "`. So the assertion `" IS " in sql` passes even if the null-safe equality (`is_not_distinct_from`, prune_audit.py:357) regresses to plain `=`, defeating the test's purpose on the SQLite case. Assert on a token unique to the equality operator (e.g. compile the equality predicate alone and check for `IS`), rather than relying on the substring that the inequality also produces.
  • superset/initialization/__init__.py - 1
    • Audit-prune diagnostic gap · Line 1058-1071
      The imports check for `deletion_retention.prune_purge_audit` is gated on `not soft_delete_enabled`, so when soft-delete is ON and the module is missing, only the purge warning (1035-1047) fires and the audit-log-growth consequence is never surfaced — even though `PURGE_AUDIT_PRUNING_ENABLED` is True. Consider mentioning the audit-prune task in the purge warning or deduplicating the two module checks.
Review Details
  • Files reviewed - 14 · Commit Range: 7c7ce70..ad34aee
    • superset/commands/deletion_retention/audit.py
    • superset/commands/deletion_retention/prune_audit.py
    • superset/config.py
    • superset/initialization/__init__.py
    • superset/migrations/versions/2026-08-24_15-50_a6c21e5b4d93_index_purge_audit_pruning.py
    • superset/migrations/versions/2026-08-24_16-20_c7f53d184ea2_coordinate_purge_audit_pruning.py
    • superset/models/purge_audit_log.py
    • superset/tasks/deletion_retention.py
    • tests/integration_tests/deletion_retention/_base.py
    • tests/integration_tests/deletion_retention/audit_tests.py
    • tests/integration_tests/deletion_retention/prune_audit_tests.py
    • tests/unit_tests/commands/deletion_retention/test_prune_audit.py
    • tests/unit_tests/initialization_test.py
    • tests/unit_tests/migrations/test_purge_audit_coordination.py
  • Files skipped - 1
    • UPDATING.md - Reason: Filter setting
  • Tools
    • MyPy (Static Code Analysis) - ✔︎ Successful
    • Astral Ruff (Static Code Analysis) - ✔︎ Successful
    • Whispers (Secret Scanner) - ✔︎ Successful
    • Detect-secrets (Secret Scanner) - ✔︎ Successful

Bito Usage Guide

Commands

Type the following command in the pull request comment and save the comment.

  • /review - Manually triggers an incremental AI Review.

  • /review full - Manually triggers a full AI Review.

  • /pause - Pauses automatic reviews on this pull request.

  • /resume - Resumes automatic reviews.

  • /resolve - Marks all Bito-posted review comments as resolved.

  • /abort - Cancels all in-progress reviews.

Refer to the documentation for additional commands.

Configuration

This repository uses Superset You can customize the agent settings here or contact your Bito workspace admin at evan@preset.io.

Documentation & Help

AI Code Review powered by Bito Logo

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

change:backend Requires changing the backend risk:db-migration PRs that require a DB migration size/XXL

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants