Files
superset2/tests/integration_tests/deletion_retention/audit_tests.py
T

525 lines
21 KiB
Python

# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
"""Integration coverage for the write-ahead purge audit record."""
from __future__ import annotations
from datetime import datetime, timedelta
from typing import Callable
from unittest.mock import MagicMock, patch
from uuid import UUID
import pytest
from sqlalchemy.orm import Session
from superset import db
from superset.commands.deletion_retention import audit
from superset.commands.deletion_retention.audit import PurgeAuditLog
from superset.models.slice import Slice
from superset.tasks.deletion_retention import _purge_impl
from ._base import DeletionRetentionTestBase
class TestPurgeAudit(DeletionRetentionTestBase):
def _get_audit_record(self, record_id: UUID) -> PurgeAuditLog:
record: PurgeAuditLog | None = db.session.get(PurgeAuditLog, record_id)
assert record is not None
return record
def _write_retention_record(
self,
*,
entity_uuid: str | None,
entity_type: str = "slices",
created_on: datetime | None = None,
) -> UUID:
record_id: UUID | None = audit.write_ahead(
trigger=audit.TRIGGER_RETENTION,
actor=audit.ACTOR_SYSTEM,
entity_type=entity_type,
entity_uuid=entity_uuid,
)
assert record_id is not None
if created_on is not None:
record: PurgeAuditLog = self._get_audit_record(record_id)
record.created_on = created_on
db.session.commit()
return record_id
def test_write_ahead_then_confirm(self) -> None:
"""A purge writes a pending audit row up front and flips it to
confirmed after the delete commits."""
chart = self.make_chart("audited")
chart_uuid = str(chart.uuid)
self.soft_delete(chart, days_ago=90)
_purge_impl(30, dry_run=False)
row = db.session.query(PurgeAuditLog).filter_by(entity_uuid=chart_uuid).one()
assert row.status == audit.STATUS_CONFIRMED
assert row.trigger == audit.TRIGGER_RETENTION
assert row.actor == audit.ACTOR_SYSTEM
assert row.confirmed_on is not None
assert isinstance(row.id, UUID)
def test_known_failure_finalizes_audit_row(self) -> None:
"""A known cascade failure is durable but does not remain pending."""
chart = self.make_chart("crash")
chart_id, chart_uuid = chart.id, str(chart.uuid)
self.soft_delete(chart, days_ago=90)
# Make the cascade blow up after the write-ahead row is committed.
with patch(
"superset.tasks.deletion_retention.cascade_hard_delete",
side_effect=RuntimeError("boom"),
):
result = _purge_impl(30, dry_run=False)
# the run records the failure and does not purge
assert result["cascade_failures"] == 1
assert self.exists(Slice, chart_id)
# the write-ahead row survives and is finalized as failed
row = db.session.query(PurgeAuditLog).filter_by(entity_uuid=chart_uuid).one()
assert row.status == audit.STATUS_FAILED
assert row.confirmed_on is None
def test_reconcile_confirms_pending_after_entity_commit(self) -> None:
"""A crash after entity commit is reconciled to confirmed."""
chart = self.make_chart("committed_crash")
chart_uuid = str(chart.uuid)
self.soft_delete(chart, days_ago=90)
with patch("superset.tasks.deletion_retention.audit.confirm"):
_purge_impl(30, dry_run=False)
row = db.session.query(PurgeAuditLog).filter_by(entity_uuid=chart_uuid).one()
assert row.status == audit.STATUS_PENDING
result = audit.reconcile_pending(
stale_before=datetime.utcnow() + timedelta(seconds=1)
)
db.session.expire_all()
assert result == {"reconciled": 1, "absent": 1, "failed": 0}
# target_absent, NOT confirmed: the entity being gone proves some
# purge committed, but a concurrent attempt or unrelated deletion fits
# the evidence equally well -- the compliance record must not
# attribute a success it did not witness.
assert row.status == audit.STATUS_TARGET_ABSENT
# No confirmed_on: nothing was confirmed -- only inferred absent.
assert row.confirmed_on is None
def test_reconcile_fails_pending_when_entity_survives(self) -> None:
"""A stale attempt with a surviving entity is closed as failed."""
chart = self.make_chart("surviving_pending")
record_id = audit.write_ahead(
trigger=audit.TRIGGER_RETENTION,
actor=audit.ACTOR_SYSTEM,
entity_type="slices",
entity_uuid=str(chart.uuid),
removed_dashboard_slices=7,
)
result = audit.reconcile_pending(
stale_before=datetime.utcnow() + timedelta(seconds=1)
)
db.session.expire_all()
assert result == {"reconciled": 1, "absent": 0, "failed": 1}
row = db.session.get(PurgeAuditLog, record_id)
assert row.status == audit.STATUS_FAILED
# The intended-removal count is zeroed, same as finalize() on a
# failed attempt: the rollback means those removals never happened.
assert row.removed_dashboard_slices == 0
def test_reconcile_zeroes_count_when_target_absent(self) -> None:
"""The intended-removal count is not attributable on target_absent.
The entity being gone proves *some* purge committed, but not that it
was this attempt -- so the write-ahead count must not survive into
the immutable record as if witnessed (mirrors finalize() zeroing on
failed/blocked).
"""
record_id = audit.write_ahead(
trigger=audit.TRIGGER_RETENTION,
actor=audit.ACTOR_SYSTEM,
entity_type="slices",
entity_uuid="00000000-0000-0000-0000-00000000feed",
removed_dashboard_slices=7,
)
result = audit.reconcile_pending(
stale_before=datetime.utcnow() + timedelta(seconds=1)
)
db.session.expire_all()
assert result == {"reconciled": 1, "absent": 1, "failed": 0}
row = db.session.get(PurgeAuditLog, record_id)
assert row.status == audit.STATUS_TARGET_ABSENT
assert row.removed_dashboard_slices == 0
def test_blocked_attempt_does_not_keep_the_intended_removal_count(self) -> None:
"""The write-ahead row records what the purge INTENDED to remove;
a blocked attempt rolled that work back, so keeping the count would
assert removals that never happened."""
record_id = audit.write_ahead(
trigger=audit.TRIGGER_RETENTION,
actor=audit.ACTOR_SYSTEM,
entity_type="dashboards",
entity_uuid="00000000-0000-0000-0000-00000000cafe",
removed_dashboard_slices=7,
)
audit.block(record_id)
row = db.session.query(PurgeAuditLog).filter_by(id=record_id).one()
assert row.status == audit.STATUS_BLOCKED
assert row.removed_dashboard_slices == 0
def test_first_retention_block_is_retained(self) -> None:
record_id: UUID = self._write_retention_record(entity_uuid="first-block")
disposition: audit.RetentionBlockedDisposition = (
audit.finalize_retention_blocked(record_id)
)
record: PurgeAuditLog = self._get_audit_record(record_id)
assert disposition == "retained"
assert record.status == audit.STATUS_BLOCKED
def test_repeated_retention_block_suppresses_current_provisional(self) -> None:
first_id: UUID = self._write_retention_record(entity_uuid="repeat-block")
audit.finalize_retention_blocked(first_id)
second_id: UUID = self._write_retention_record(entity_uuid="repeat-block")
disposition: audit.RetentionBlockedDisposition = (
audit.finalize_retention_blocked(second_id)
)
assert disposition == "suppressed"
first: PurgeAuditLog = self._get_audit_record(first_id)
assert first.status == audit.STATUS_BLOCKED
assert db.session.get(PurgeAuditLog, second_id) is None
def test_equal_timestamp_is_ambiguous_and_retains_current(self) -> None:
timestamp: datetime = datetime.utcnow()
first_id: UUID = self._write_retention_record(
entity_uuid="equal-time", created_on=timestamp
)
audit.finalize_retention_blocked(first_id)
second_id: UUID = self._write_retention_record(
entity_uuid="equal-time", created_on=timestamp
)
disposition: audit.RetentionBlockedDisposition = (
audit.finalize_retention_blocked(second_id)
)
assert disposition == "retained"
second: PurgeAuditLog = self._get_audit_record(second_id)
assert second.status == audit.STATUS_BLOCKED
def test_tied_mixed_predecessors_are_ambiguous_and_retain_current(self) -> None:
timestamp: datetime = datetime.utcnow()
blocked_id: UUID = self._write_retention_record(
entity_uuid="mixed-tie", created_on=timestamp
)
audit.finalize(blocked_id, audit.STATUS_BLOCKED)
failed_id: UUID = self._write_retention_record(
entity_uuid="mixed-tie", created_on=timestamp
)
audit.finalize(failed_id, audit.STATUS_FAILED)
current_id: UUID = self._write_retention_record(
entity_uuid="mixed-tie", created_on=timestamp + timedelta(seconds=1)
)
session: Session = audit._dedicated_session()
try:
current: PurgeAuditLog | None = session.get(PurgeAuditLog, current_id)
assert current is not None
predecessor: PurgeAuditLog | None = audit._retention_predecessor(
session, current
)
finally:
session.close()
disposition: audit.RetentionBlockedDisposition = (
audit.finalize_retention_blocked(current_id)
)
assert predecessor is None
assert disposition == "retained"
retained: PurgeAuditLog = self._get_audit_record(current_id)
assert retained.status == audit.STATUS_BLOCKED
def test_newer_concurrent_row_does_not_become_a_predecessor(self) -> None:
current_time: datetime = datetime.utcnow()
current_id: UUID = self._write_retention_record(
entity_uuid="overlap", created_on=current_time
)
newer_id: UUID = self._write_retention_record(
entity_uuid="overlap", created_on=current_time + timedelta(seconds=1)
)
audit.finalize_retention_blocked(newer_id)
disposition: audit.RetentionBlockedDisposition = (
audit.finalize_retention_blocked(current_id)
)
assert disposition == "retained"
current: PurgeAuditLog = self._get_audit_record(current_id)
assert current.status == audit.STATUS_BLOCKED
def test_pending_predecessor_retains_current_block(self) -> None:
timestamp: datetime = datetime.utcnow()
self._write_retention_record(entity_uuid="pending-prior", created_on=timestamp)
current_id: UUID = self._write_retention_record(
entity_uuid="pending-prior", created_on=timestamp + timedelta(seconds=1)
)
disposition: audit.RetentionBlockedDisposition = (
audit.finalize_retention_blocked(current_id)
)
assert disposition == "retained"
def test_null_uuid_retains_blocked_evidence(self) -> None:
null_id: UUID = self._write_retention_record(entity_uuid=None)
disposition: audit.RetentionBlockedDisposition = (
audit.finalize_retention_blocked(null_id)
)
record: PurgeAuditLog = self._get_audit_record(null_id)
assert disposition == "retained"
assert record.status == audit.STATUS_BLOCKED
assert record.removed_dashboard_slices == 0
def test_same_uuid_across_entity_types_does_not_suppress(self) -> None:
chart_id: UUID = self._write_retention_record(
entity_uuid="shared-type", entity_type="slices"
)
audit.finalize_retention_blocked(chart_id)
dashboard_id: UUID = self._write_retention_record(
entity_uuid="shared-type", entity_type="dashboards"
)
dashboard_disposition: audit.RetentionBlockedDisposition = (
audit.finalize_retention_blocked(dashboard_id)
)
assert dashboard_disposition == "retained"
def test_completed_current_record_is_immutable(self) -> None:
record_id: UUID = self._write_retention_record(entity_uuid="completed")
audit.fail(record_id)
disposition: audit.RetentionBlockedDisposition = (
audit.finalize_retention_blocked(record_id)
)
record: PurgeAuditLog = self._get_audit_record(record_id)
assert disposition == "retained"
assert record.status == audit.STATUS_FAILED
def test_predecessor_lookup_failure_recovers_blocked_evidence(self) -> None:
record_id: UUID = self._write_retention_record(entity_uuid="lookup-failure")
with patch(
"superset.commands.deletion_retention.audit._retention_predecessor",
side_effect=audit.SQLAlchemyError("lookup failed"),
):
disposition: audit.RetentionBlockedDisposition = (
audit.finalize_retention_blocked(record_id)
)
record: PurgeAuditLog = self._get_audit_record(record_id)
assert disposition == "fallback"
assert record.status == audit.STATUS_BLOCKED
def test_suppression_delete_failure_recovers_blocked_evidence(self) -> None:
first_id: UUID = self._write_retention_record(entity_uuid="delete-failure")
audit.finalize_retention_blocked(first_id)
current_id: UUID = self._write_retention_record(entity_uuid="delete-failure")
with patch(
"superset.commands.deletion_retention.audit._suppress_redundant_block",
side_effect=audit.SQLAlchemyError("delete failed"),
):
disposition: audit.RetentionBlockedDisposition = (
audit.finalize_retention_blocked(current_id)
)
record: PurgeAuditLog = self._get_audit_record(current_id)
assert disposition == "fallback"
assert record.status == audit.STATUS_BLOCKED
def test_commit_failure_recovers_blocked_evidence(self) -> None:
record_id: UUID = self._write_retention_record(entity_uuid="commit-failure")
primary_session: Session = audit._dedicated_session()
recovery_session: Session = audit._dedicated_session()
with (
patch.object(
primary_session,
"commit",
side_effect=audit.SQLAlchemyError("commit failed"),
),
patch(
"superset.commands.deletion_retention.audit._dedicated_session",
side_effect=[primary_session, recovery_session],
),
):
disposition: audit.RetentionBlockedDisposition = (
audit.finalize_retention_blocked(record_id)
)
record: PurgeAuditLog = self._get_audit_record(record_id)
assert disposition == "fallback"
assert record.status == audit.STATUS_BLOCKED
def test_uncertain_suppression_commit_recreates_absent_evidence(self) -> None:
first_id: UUID = self._write_retention_record(entity_uuid="absent-current")
audit.finalize_retention_blocked(first_id)
current_id: UUID = self._write_retention_record(entity_uuid="absent-current")
primary_session: Session = audit._dedicated_session()
recovery_session: Session = audit._dedicated_session()
primary_commit: Callable[[], None] = primary_session.commit
def commit_then_raise() -> None:
primary_commit()
raise audit.SQLAlchemyError("commit acknowledgement lost")
with (
patch.object(primary_session, "commit", side_effect=commit_then_raise),
patch(
"superset.commands.deletion_retention.audit._dedicated_session",
side_effect=[primary_session, recovery_session],
),
):
disposition: audit.RetentionBlockedDisposition = (
audit.finalize_retention_blocked(current_id)
)
record: PurgeAuditLog = self._get_audit_record(current_id)
assert disposition == "fallback"
assert record.status == audit.STATUS_BLOCKED
def test_failed_fallback_leaves_pending_evidence_for_reconciliation(self) -> None:
record_id: UUID = self._write_retention_record(entity_uuid="fallback-failure")
primary_session: Session = audit._dedicated_session()
recovery_session: Session = audit._dedicated_session()
with (
patch.object(
primary_session,
"commit",
side_effect=audit.SQLAlchemyError("primary commit failed"),
),
patch.object(
recovery_session,
"commit",
side_effect=audit.SQLAlchemyError("recovery commit failed"),
),
patch(
"superset.commands.deletion_retention.audit._dedicated_session",
side_effect=[primary_session, recovery_session],
),
):
disposition: audit.RetentionBlockedDisposition = (
audit.finalize_retention_blocked(record_id)
)
record: PurgeAuditLog = self._get_audit_record(record_id)
assert disposition == "fallback"
assert record.status == audit.STATUS_PENDING
def test_indeterminate_delete_rowcount_forces_persistence_recovery(self) -> None:
timestamp: datetime = datetime.utcnow()
current: PurgeAuditLog = PurgeAuditLog(
id=UUID("00000000-0000-0000-0000-000000000002"),
status=audit.STATUS_PENDING,
trigger=audit.TRIGGER_RETENTION,
actor=audit.ACTOR_SYSTEM,
entity_type="slices",
entity_uuid="indeterminate-rowcount",
created_on=timestamp,
)
predecessor: PurgeAuditLog = PurgeAuditLog(
id=UUID("00000000-0000-0000-0000-000000000001"),
status=audit.STATUS_BLOCKED,
trigger=audit.TRIGGER_RETENTION,
actor=audit.ACTOR_SYSTEM,
entity_type="slices",
entity_uuid="indeterminate-rowcount",
created_on=timestamp - timedelta(seconds=1),
)
result: MagicMock = MagicMock(rowcount=-1)
session: MagicMock = MagicMock()
session.execute.return_value = result
with pytest.raises(audit.SQLAlchemyError, match="indeterminate"):
audit._suppress_redundant_block(session, current, predecessor)
def test_overlap_duplicates_do_not_cause_unbounded_sequential_growth(self) -> None:
timestamp: datetime = datetime.utcnow()
first_id: UUID = self._write_retention_record(
entity_uuid="bounded-overlap", created_on=timestamp
)
audit.finalize_retention_blocked(first_id)
overlap_id: UUID = self._write_retention_record(
entity_uuid="bounded-overlap", created_on=timestamp
)
audit.finalize_retention_blocked(overlap_id)
later_id: UUID = self._write_retention_record(
entity_uuid="bounded-overlap",
created_on=timestamp + timedelta(seconds=1),
)
disposition: audit.RetentionBlockedDisposition = (
audit.finalize_retention_blocked(later_id)
)
retained_count: int = (
db.session.query(PurgeAuditLog)
.filter_by(entity_uuid="bounded-overlap")
.count()
)
assert disposition == "suppressed"
assert retained_count == 2
def test_meaningful_outcome_transitions_start_new_blocked_periods(self) -> None:
transition: tuple[int, str]
for transition in enumerate(
(
audit.STATUS_FAILED,
audit.STATUS_CONFIRMED,
audit.STATUS_TARGET_ABSENT,
)
):
index: int = transition[0]
status: str = transition[1]
entity_uuid: str = f"transition-{index}"
prior_id: UUID = self._write_retention_record(entity_uuid=entity_uuid)
audit.finalize(prior_id, status)
current_id: UUID = self._write_retention_record(entity_uuid=entity_uuid)
disposition: audit.RetentionBlockedDisposition = (
audit.finalize_retention_blocked(current_id)
)
current: PurgeAuditLog = self._get_audit_record(current_id)
assert disposition == "retained"
assert current.status == audit.STATUS_BLOCKED