Skip to content

symfonic.capabilities.memory.graph_forget

graph_forget

Private exact-backend bridge for atomic native subtree erasure.

deferred_acknowledgement

deferred_acknowledgement()

The caller owns the transaction and acknowledges after it commits.

Source code in src/symfonic/capabilities/memory/graph_forget.py
@contextmanager
def deferred_acknowledgement():
    """The caller owns the transaction and acknowledges after it commits."""
    token = _DEFERRED.set(True)
    try:
        yield
    finally:
        _DEFERRED.reset(token)

forget_subtree async

forget_subtree(store: Any, scope: Any, *, operation: Any = None)

Bound graph-only native PG erasure before entering its mutation fence.

Source code in src/symfonic/capabilities/memory/graph_forget.py
async def forget_subtree(store: Any, scope: Any, *, operation: Any = None):
    """Bound graph-only native PG erasure before entering its mutation fence."""
    from symfonic.capabilities.memory import graph_receipt
    from symfonic.capabilities.memory.errors import MemoryContractError
    from symfonic.capabilities.memory.graph_direct_claims import (
        external_transaction,
        retire_subtree,
    )
    from symfonic.capabilities.memory.graph_rows import _adapter_scope
    from symfonic.capabilities.memory.records import ForgetInProgress, LifecycleReceipt
    from symfonic.memory.backends.postgres_erase_routing import LargeEraseRequired

    legacy = _adapter_scope(scope)
    deleter = native_subtree_deleter(store._graph) if scope.identity_version else None
    options = {"native_forget": True} if deleter is not None and store._vectors is None else {}
    outer = external_transaction(store, scope)
    proof = _proof(deleter)
    if proof is not None:  # CORE-171: refuse before any delete without the anchored lineage
        if outer and not _DEFERRED.get():
            raise MemoryContractError(UNANCHORABLE)
        await _verified(proof, deleter.pool)
    hold = (
        graph_receipt.mutation_hold(
            store, scope, operation, graph_receipt.MemoryOperationVerb.FORGET
        )
        if graph_receipt.domain_of(store) is not None
        else store._mutations.hold(legacy, **options)
    )
    try:
        async with hold:
            claims = store._direct_claims.copy()
            if deleter is not None:
                doomed = await deleter.delete_native_subtree_ids(legacy)
            else:
                nodes = await store._graph.query_subtree(legacy, {}, limit=None)
                doomed = tuple(sorted(str(node.id) for node in nodes))
                await store._graph.delete_subtree(legacy)
            if store._recall is not None:
                try:
                    await store._recall.forget_subtree(legacy)
                except MemoryContractError as error:
                    await _route_large_vector(store, deleter, error)
                    raise
    except LargeEraseRequired as signal:
        if deleter is None:
            raise
        claims = store._direct_claims.copy()
        step = await _erase_large(store, deleter, legacy, signal, outer)
        retire_subtree(store, scope, outer, claims)
        if not step.complete:
            return ForgetInProgress(scope.path, step.job_id, step.discarded)
        doomed = step.discarded
    else:
        retire_subtree(store, scope, outer, claims)
    if proof is not None and not outer:  # acknowledged only once anchored in-lineage
        await proof.acknowledge(deleter.pool)
    return LifecycleReceipt(scope_path=scope.path, discarded=tuple(doomed))

native_subtree_deleter

native_subtree_deleter(graph: Any) -> Any | None

Return the durable PostgreSQL deleter only for a safe graph shape.

A cycle journal may have staged graph mutations. Bypassing its wrapper would erase durable rows while the staged view still reports them.

Source code in src/symfonic/capabilities/memory/graph_forget.py
def native_subtree_deleter(graph: Any) -> Any | None:
    """Return the durable PostgreSQL deleter only for a safe graph shape.

    A cycle journal may have staged graph mutations. Bypassing its wrapper
    would erase durable rows while the staged view still reports them.
    """
    from symfonic.capabilities.memory.journal import JournalledGraph, current_journal
    from symfonic.memory.backends.postgres_graph import PostgresGraphBackend
    from symfonic.memory.backends.postgres_graph_runtime import PostgresGraphRuntime

    # The nonowner runtime adapter only replaces ensure_schema; the eraser's
    # D6 deployment runs the turn runtime as exactly that login.
    exact = (PostgresGraphBackend, PostgresGraphRuntime)
    if type(graph) in exact:
        return graph
    if (
        type(graph) is JournalledGraph
        and current_journal() is None
        and type(graph.durable) in exact
    ):
        return graph.durable
    return None