Skip to content

symfonic.platform.erasure

erasure

PRIV-3 — the erasure saga: durable, resumable, bounded, fail-closed.

What this replaces is a for loop in a request handler. If the process dies at store four of nine, the shipped path leaves the subject's data half gone, records nothing about which half, and produces an "initiated" audit row as its only artefact. The saga writes per-participant state before and after every attempt, so a resumed run knows exactly what is outstanding — and a read of the subject stays suppressed until nothing is.

Three rules keep it honest:

  • Enumerate, never capture. The participant list is read from the registry on every pass (PRIV-1), so a store registered while the saga was running is swept rather than skipped.
  • Confirm, never assume. A participant is done when confirm_absent returns true (SEC-PRIV-3), not when erase_subject returned a number. A store that erases confidently and still holds rows is exactly what verification-by-absence exists to catch.
  • Survive one bad backend. A participant that raises is recorded and the sweep continues. Aborting would skip every participant sorted after the broken one and lose the completion trail with it.

ErasureOutcome dataclass

ErasureOutcome(scope_key: str, complete: bool, counts: dict[str, int] = dict(), unconfirmed: tuple[str, ...] = (), errors: dict[str, str] = dict(), generation: int = 0)

What one pass of the saga achieved. Partial success is a real answer.

ErasureSaga

ErasureSaga(*, registry: SubjectDataStoreRegistry, fence: ErasureFence, saga_store: ErasureSagaStore, audit: AuditSeam | None = None, max_attempts: int = 3)

Drives every registered participant to a confirmed absence.

Source code in src/symfonic/platform/erasure.py
def __init__(
    self,
    *,
    registry: SubjectDataStoreRegistry,
    fence: ErasureFence,
    saga_store: ErasureSagaStore,
    audit: AuditSeam | None = None,
    max_attempts: int = 3,
) -> None:
    if max_attempts < 1:
        raise ValueError("max_attempts must be at least 1")
    self._registry = registry
    self._fence = fence
    self._saga_store = saga_store
    self._audit = audit if audit is not None else AuditSeam()
    self._max_attempts = max_attempts

run async

run(scope: SubjectScope) -> ErasureOutcome

One bounded pass. Idempotent, and safe to call again after a crash.

Source code in src/symfonic/platform/erasure.py
async def run(self, scope: SubjectScope) -> ErasureOutcome:
    """One bounded pass. Idempotent, and safe to call again after a crash."""
    generation = await self._fence.read_generation(scope.scope_key)
    if not generation.tombstoned:
        raise ErasureFenceDenied(
            f"no subject tombstone is published for {scope.scope_key!r}; "
            "running the saga first would race every live writer, which is "
            "the whole reason the tombstone goes first (PRIV-4)",
            reason="generation_mismatch",
            scope_key=scope.scope_key,
            observed_generation=generation.generation,
        )

    state = await self._saga_store.start(
        scope.scope_key, participants=self._registry.participant_ids()
    )
    for participant_id in state.unconfirmed():
        state = await self._sweep_one(scope, participant_id)

    if state.complete and await self._saga_store.claim_generation_advance(
        scope.scope_key
    ):
        # Claim first, advance second. "Read the flag, then advance" puts an
        # await between the check and the act, and two runs for one scope —
        # an operator retry racing a scheduled `resume` — would then both
        # advance the generation. Claiming loses at most one advance if the
        # fence then fails; the tombstone, which is what actually bars
        # writers, stands either way.
        generation = await self._fence.complete_erasure(scope.scope_key)
        state = await self._saga_store.read(scope.scope_key) or state
    else:
        generation = await self._fence.read_generation(scope.scope_key)

    return ErasureOutcome(
        scope_key=scope.scope_key,
        complete=state.complete,
        counts=state.counts(),
        unconfirmed=state.unconfirmed(),
        errors=state.errors(),
        generation=generation.generation,
    )