Skip to content

feat(deletion-retention): persist purge block reason codes on the audit log - #43485

Merged
rusackas merged 4 commits into
apache:masterfrom
mikebridge:sc-115342-purge-block-reasons
Aug 25, 2026
Merged

feat(deletion-retention): persist purge block reason codes on the audit log#43485
rusackas merged 4 commits into
apache:masterfrom
mikebridge:sc-115342-purge-block-reasons

Conversation

@mikebridge

@mikebridge mikebridge commented Aug 24, 2026

Copy link
Copy Markdown
Contributor

SUMMARY

Blocked purge audit records currently record only a status: an auditor reading purge_audit_log cannot distinguish "blocked because an alert/report references the entity" from "blocked because a user has the dashboard as their welcome page" from "hit an unexpected database integrity constraint during the cascade" — outcomes with entirely different remediations. The reason is already known at decision time (logs, CLI output) but is discarded before it reaches the durable record.

This PR threads a stable machine-readable reason code from the purge decision points into the audit record, and amends the #42863 growth-suppression predicate to key on status and reason:

  • Vocabulary: REASON_REPORT_SCHEDULE ("report_schedule"), REASON_USER_ATTRIBUTE ("user_attribute"), and REASON_CASCADE_INTEGRITY_FAILURE ("cascade_integrity_failure") declared in purge_policy.py (the vocabulary's owner; deliberately model-free). The values are frozen identifiers pinned by a golden-set test — a future physical table rename must not re-mint persisted history. BlockerReason keeps each code and operator phrase together through DependencyPolicy, PurgeBlockedError, and CascadeResult; derived read-only compatibility properties preserve existing call sites; the first declared blocker that matches wins (declaration order is part of the contract, unit- and integration-tested).
  • Threading: CascadeResult.blocker is set by both cascade handlers (policy block from the exception; IntegrityErrorcascade_integrity_failure), with blocked_reason and blocked_reason_code derived from that value; audit.block() and audit.finalize_retention_blocked() take the reason as a required parameter, captured in the recovery snapshot from the finalization argument and carried through both crash-recovery branches (never read from the reason-less pending row).
  • Reading rule: a cascade integrity failure keeps blocked status (so growth suppression continues to apply) — status = blocked AND reason = cascade_integrity_failure denotes a cascade-coverage defect, not an intended policy outcome. Run-level metrics conflate this class with reference blocks, making the persisted code the sole durable classifier.
  • Suppression amendment: same-status-same-reason nights stay suppressed; a reason change writes exactly one new blocked row carrying the new code. The predecessor query considers the latest same-entity retention row overall. A visible row with a later created_on timestamp causes fail-safe retention instead of suppression against an older row; created_on orders this predicate but does not establish causal order across workers. NULL-safe both ways: a reason-less (pre-upgrade) predecessor never matches, and a missing current code never suppresses (WARNING, fail-open). Timestamp-tie ambiguity widens to "differs in status or reason" via is_distinct_from (NULL-safe on PostgreSQL/MySQL/SQLite).
  • Strictly reason-less: failed, confirmed, race-lost, and reconciled rows never carry a reason. The known crash window (worker dies between the blocked decision and finalization; reconcile_pending later finalizes failed/NULL) is documented and deliberately not "fixed" by fabricating a code — the next night re-anchors with the real one.
  • Migration: one additive nullable String(64) column via the shared migration utils. No backfill, no index, no reader changes; historical rows keep NULL and every reader tolerates absence. The fix(deletion-retention): dedupe repeated blocked audits #42863 suppression paragraph in UPDATING.md is amended to state the new predicate.

BEFORE/AFTER SCREENSHOTS OR ANIMATED GIF

Backend-only. Before/after of the audit table for the same three blocked outcomes:

-- BEFORE
status  | reason
blocked | (column does not exist)
blocked |
blocked |

-- AFTER
status  | reason
blocked | report_schedule
blocked | user_attribute
blocked | cascade_integrity_failure

TESTING INSTRUCTIONS

# unit: vocabulary golden set, threading, first-match
pytest tests/unit_tests/commands/deletion_retention/ tests/unit_tests/tasks/test_deletion_retention.py

# integration: three-way distinction, suppression contract incl. reason-change,
# NULL-safety, latest-predecessor ordering, recovery branches, immutability
pytest tests/integration_tests/deletion_retention/

# migration round-trip (verified on SQLite and PostgreSQL)
superset db upgrade && superset db downgrade 1072de5ed955 && superset db upgrade

Manual: soft-delete a chart referenced by an active report, run the retention purge (or superset deletion-retention force-purge), then SELECT status, reason FROM purge_audit_log — the blocked row carries report_schedule.

The changed-reason suppression test was control-run: it fails against the pre-amendment status-only predicate and passes with this PR.

ADDITIONAL INFORMATION

  • Has associated issue:
  • Required feature flags:
  • Changes UI
  • Includes DB Migration (follow approval process in SIP-59)
    • Migration is atomic, supports rollback & is backwards-compatible
    • Confirm DB migration upgrade and downgrade tested
    • Runtime estimates and downtime expectations provided
  • Introduces new feature or API
  • Removes existing feature or API

Runtime: metadata-only DDL on PostgreSQL/SQLite; instant DDL on MySQL 8.0.12+ (trailing nullable ADD COLUMN); table rebuild only on older MySQL — purge_audit_log is small and growth-suppressed by design. No downtime expected. Single alembic head verified (1072de5ed955) before authoring.

Internal tracking: sc-115342. Follows #42863 (audit dedupe) and #42888 (declarative purge policies).

This PR was generated by Claude (AI) on behalf of @mikebridge.

🤖 Generated with Claude Code

…urge audit log

Blocked purge audit records gain a stable machine-readable reason code
(report_schedule, user_attribute, unhandled_reference) so the audit table
alone answers why an entity was not purged. The suppression predicate now
keys on status AND reason: same-reason nights stay suppressed, a reason
change writes exactly one new blocked row, and the predecessor is the
latest same-entity row overall so clock skew can never suppress against a
stale row. NULL-safe both ways: pre-feature reason-less predecessors never
match, and a missing current code never suppresses (warned, fail-open).
Failed, confirmed, and reconciled rows never carry a reason. One additive
nullable migration; no backfill.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@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 #6ac23b

Actionable Suggestions - 0
Filtered by Review Rules

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

  • superset/commands/deletion_retention/audit.py - 1
    • Missing reason parameter in recovery fallback · Line 412-412
Review Details
  • Files reviewed - 11 · Commit Range: 2b0b72a..2b0b72a
    • superset/commands/deletion_retention/audit.py
    • superset/commands/deletion_retention/force_purge.py
    • superset/commands/deletion_retention/purge_cascade.py
    • superset/commands/deletion_retention/purge_policy.py
    • superset/migrations/versions/2026-08-24_12-00_39097d124752_add_reason_to_purge_audit_log.py
    • superset/models/purge_audit_log.py
    • superset/tasks/deletion_retention.py
    • tests/integration_tests/deletion_retention/audit_tests.py
    • tests/integration_tests/deletion_retention/force_purge_tests.py
    • tests/integration_tests/deletion_retention/purge_tests.py
    • tests/unit_tests/commands/deletion_retention/test_reason_codes.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 risk:db-migration PRs that require a DB migration label Aug 24, 2026
@netlify

netlify Bot commented Aug 24, 2026

Copy link
Copy Markdown

Deploy Preview for superset-docs-preview ready!

Name Link
🔨 Latest commit 2b0b72a
🔍 Latest deploy log https://app.netlify.com/projects/superset-docs-preview/deploys/6a8c99d4153dc500086008c9
😎 Deploy Preview https://deploy-preview-43485--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 93.54839% with 4 lines in your changes missing coverage. Please review.
✅ Project coverage is 78.88%. Comparing base (8fa48d7) to head (1dc5795).
⚠️ Report is 50 commits behind head on master.

Files with missing lines Patch % Lines
...perset/commands/deletion_retention/purge_policy.py 88.46% 2 Missing and 1 partial ⚠️
superset/tasks/deletion_retention.py 90.00% 0 Missing and 1 partial ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##           master   #43485      +/-   ##
==========================================
+ Coverage   78.85%   78.88%   +0.02%     
==========================================
  Files        2876     2876              
  Lines      164601   165059     +458     
  Branches    38015    38095      +80     
==========================================
+ Hits       129799   130203     +404     
- Misses      32355    32394      +39     
- Partials     2447     2462      +15     
Flag Coverage Δ
hive 38.02% <48.38%> (-0.05%) ⬇️
mysql 57.77% <91.93%> (-0.03%) ⬇️
postgres 57.80% <91.93%> (-0.03%) ⬇️
presto 39.95% <48.38%> (-0.06%) ⬇️
python 83.57% <93.54%> (+0.02%) ⬆️
sqlite 57.49% <91.93%> (-0.03%) ⬇️
unit 73.64% <61.29%> (+0.09%) ⬆️

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.

…itly

An eight-lens review panel converged on the reason code being derived from
the blocker's physical table name while the module comment promised a table
rename must not change a persisted code. The blocker map is now keyed by
related table with a BlockerReason(code, phrase) value, so the code is
declared and a rename touches only the lookup key. A new test pins that
decoupling.

Also from the panel: finalize() takes reason as an explicit keyword and
persists it only for blocked outcomes (the documented invariant is now
structural, with a test); a missing reason code is counted as
blocked_audit_missing_reason instead of logging a second warning for one
event; the migration and UPDATING.md document the migrate-before-deploy
ordering, its fail-closed symptom, the rolling-deploy window, and the
downgrade's data loss; reconcile_pending documents the blocked-attempt
demotion; PEP 257 and deferred-import justification comments.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@mikebridge mikebridge changed the title feat(deletion-retention): persist block/failure reason codes on the purge audit log feat(deletion-retention): persist purge block reason codes on the audit log Aug 24, 2026
@bito-code-review

bito-code-review Bot commented Aug 24, 2026

Copy link
Copy Markdown
Contributor

Code Review Agent Run #8f8a14

Actionable Suggestions - 0
Filtered by Review Rules

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

  • superset/models/purge_audit_log.py - 1
    • Inconsistent Column type annotation · Line 80-80
Review Details
  • Files reviewed - 10 · Commit Range: 2b0b72a..5249068
    • superset/commands/deletion_retention/audit.py
    • superset/commands/deletion_retention/purge_policy.py
    • superset/migrations/versions/2026-08-24_12-00_39097d124752_add_reason_to_purge_audit_log.py
    • superset/tasks/deletion_retention.py
    • tests/integration_tests/deletion_retention/audit_tests.py
    • tests/unit_tests/commands/deletion_retention/test_reason_codes.py
    • superset/commands/deletion_retention/force_purge.py
    • superset/commands/deletion_retention/purge_cascade.py
    • superset/models/purge_audit_log.py
    • tests/integration_tests/deletion_retention/purge_tests.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

@rebenitez1802
rebenitez1802 self-requested a review August 25, 2026 09:00

@rebenitez1802 rebenitez1802 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.

Approve — well-scoped, correctly fail-safe, and the risky parts hold up. I traced the concurrency-sensitive suppression predicate, the cross-dialect NULL-safety, the BlockerReason refactor threading, and the migration; an un-migrated table was confirmed to make the scheduled purge fail closed. No blocking bug. Two non-blocking Mediums worth addressing:

🟡 Medium — blocked_audit_missing_reason metric is dead code that defeats its own safety net

In _finalize_blocked (superset/tasks/deletion_retention.py:209-219) the if result.blocker is None: branch that increments blocked_audit_missing_reason is unreachable: its only caller gates on elif result.blocked_reason is not None: (:305), and CascadeResult.blocked_reason is None iff blocker is None (property in purge_cascade.py). So the metric can never fire, and the inline comment ("one gap produces one alertable signal") describes an impossible state — anyone told to watch this signal will always see zero. A genuinely blocker-less "blocked" result would instead fall to else: audit.fail(record_id) and be recorded FAILED, not BLOCKED. No test references the metric. Fix: remove the dead branch (and its comment), or relocate the emptiness check to where a blocked-but-blocker-less result is actually representable.

🟡 Medium — Oscillating block reason grows the audit table without bound (undisclosed)

_suppress_redundant_block (superset/commands/deletion_retention/audit.py:285-322) suppresses only when the single latest same-entity row carries the same reason. If an entity's reason alternates each run (e.g. external automation that adds/removes a report_schedule reference on alternating nights, flipping between report_schedule and cascade_integrity_failure), no row ever matches its predecessor and one permanent row accumulates per run indefinitely. Pre-PR (status-only dedupe) that entity stayed at one row, so this is an undisclosed growth regression, and purge_audit_log has no TTL of its own. Rows are tiny and content-free and the churn pattern is narrow, but the growth is genuinely unbounded. Fix: disclose this edge case in UPDATING.md and consider an audit-table retention job.

@rusackas
rusackas merged commit 88d2c29 into apache:master Aug 25, 2026
74 checks passed
@bito-code-review

Copy link
Copy Markdown
Contributor

Bito Automatic Review Skipped – PR Already Merged

Bito scheduled an automatic review for this pull request, but the review was skipped because this PR was merged before the review could be run.
No action is needed if you didn't intend to review it. To get a review, you can type /review in a comment and save it

mikebridge pushed a commit to mikebridge/superset that referenced this pull request Aug 25, 2026
…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>
mikebridge pushed a commit to mikebridge/superset that referenced this pull request Sep 4, 2026
…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>
mikebridge pushed a commit to mikebridge/superset that referenced this pull request Sep 4, 2026
…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>
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/XL

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants