Skip to content

symfonic.services.effects.accounting

accounting

What a revert is entitled to say, derived from what the fence reached.

This is the pessimistic half of revert.py, separated from it because the two halves answer different questions and fail differently. revert.py acts: it raises the fence, revokes leases, requests cancellation, drives the drain. Nothing here acts on the system at all — every method turns the set of invocations the fence covered into a record, and the only mutation any of them performs is the trust demotion OpaqueExposureAccountant owns.

Keeping them apart matters because the accounting is the part that must never overstate. A change to the drain or the linearization point cannot silently change what the incident claims, and a reviewer auditing the claims does not have to read the concurrency to do it.

RevertAccounting

RevertAccounting(*, accountant: OpaqueExposureAccountant, containment: ContainmentCoordinator, ports: FencedPortRegistry)

Turns the fenced set into exposures, claims, and blocked cutovers.

Source code in src/symfonic/services/effects/accounting.py
def __init__(
    self,
    *,
    accountant: OpaqueExposureAccountant,
    containment: ContainmentCoordinator,
    ports: FencedPortRegistry,
) -> None:
    self._accountant = accountant
    self._containment = containment
    self._ports = ports

blocked

blocked(exposures: tuple[ExposureRecord, ...]) -> tuple[str, ...]

Ports whose exposure has no approved rule, so cutover cannot proceed.

Source code in src/symfonic/services/effects/accounting.py
def blocked(self, exposures: tuple[ExposureRecord, ...]) -> tuple[str, ...]:
    """Ports whose exposure has no approved rule, so cutover cannot proceed."""
    return self._containment.registry.cutover_blocked_ports(
        e.subject for e in exposures if e.kind is ExposureKind.IRREVERSIBLE_EFFECT
    )

claims

claims(invocations: tuple[str, ...], exposures: tuple[ExposureRecord, ...]) -> tuple[FencingClaim, ...]

One claim per affected invocation, over both axes of exposure.

The accountant only knows extensions. An invocation that ran none but whose irreversible effect had already left the process is still exposed, and printing SUPPRESSED beside its own exposure record would be precisely the overstatement this package exists to prevent — so the port-crossing exposures override a clean extension verdict here.

Source code in src/symfonic/services/effects/accounting.py
def claims(
    self, invocations: tuple[str, ...], exposures: tuple[ExposureRecord, ...]
) -> tuple[FencingClaim, ...]:
    """One claim per affected invocation, over *both* axes of exposure.

    The accountant only knows extensions. An invocation that ran none but
    whose irreversible effect had already left the process is still
    exposed, and printing SUPPRESSED beside its own exposure record would
    be precisely the overstatement this package exists to prevent — so the
    port-crossing exposures override a clean extension verdict here.
    """
    exposed: dict[str, str] = {}
    for exposure in exposures:
        if exposure.kind is ExposureKind.IRREVERSIBLE_EFFECT:
            exposed.setdefault(exposure.invocation_id, exposure.reason)
    claims: list[FencingClaim] = []
    for invocation_id in invocations:
        claim = self._accountant.claim_for(invocation_id)
        reason = exposed.get(invocation_id)
        if claim.suppressed and reason is not None:
            claim = FencingClaim(
                invocation_id=invocation_id,
                suppressed=False,
                withheld_reason=(
                    f"an irreversible effect had already been dispatched: {reason}"
                ),
            )
        claims.append(claim)
    return tuple(claims)

contain_all

contain_all(exposures: tuple[ExposureRecord, ...]) -> tuple[CompensationOutcome, ...]

Run the approved compensation for each exposure, or record its absence.

Source code in src/symfonic/services/effects/accounting.py
def contain_all(
    self, exposures: tuple[ExposureRecord, ...]
) -> tuple[CompensationOutcome, ...]:
    """Run the approved compensation for each exposure, or record its absence."""
    return self._containment.contain_all(exposures)

exposures

exposures(dispatched: list[EffectTicket], invocations: tuple[str, ...]) -> tuple[ExposureRecord, ...]

Irreversible effects that already left, plus every opaque extension.

Source code in src/symfonic/services/effects/accounting.py
def exposures(
    self, dispatched: list[EffectTicket], invocations: tuple[str, ...]
) -> tuple[ExposureRecord, ...]:
    """Irreversible effects that already left, plus every opaque extension."""
    records = [
        ExposureRecord(
            invocation_id=ticket.invocation_id,
            kind=ExposureKind.IRREVERSIBLE_EFFECT,
            subject=ticket.port_id,
            reason=(
                f"{ticket.port_id} was dispatched before the fence closed; "
                f"{self._ports.port(ticket.port_id).rationale}, so cancellation "
                "did not undo it"
            ),
            ticket_id=ticket.ticket_id,
        )
        for ticket in dispatched
        if ticket.irreversible
    ]
    records.extend(self._accountant.account(invocations))
    return tuple(records)

invocations staticmethod

invocations(covered: tuple[EffectLease, ...], affected: tuple[EffectTicket, ...]) -> tuple[str, ...]

Every invocation the fence reached, whether or not it crossed a port.

Effect tickets are the wrong sole key here. An opaque in-process extension performs its effects without crossing a classified port, so an invocation can hold a fenced lease and own no ticket at all — exactly the invocation a revert must never leave out of the record. An invocation is accounted for because it was authorized under the rejected generation, not because it happened to cross a port the framework can see.

Source code in src/symfonic/services/effects/accounting.py
@staticmethod
def invocations(
    covered: tuple[EffectLease, ...], affected: tuple[EffectTicket, ...]
) -> tuple[str, ...]:
    """Every invocation the fence reached, whether or not it crossed a port.

    Effect tickets are the wrong sole key here. An opaque in-process
    extension performs its effects without crossing a classified port, so
    an invocation can hold a fenced lease and own no ticket at all —
    exactly the invocation a revert must never leave out of the record. An
    invocation is accounted for because it was *authorized* under the
    rejected generation, not because it happened to cross a port the
    framework can see.
    """
    return tuple(
        dict.fromkeys(
            [lease.invocation_id for lease in covered]
            + [ticket.invocation_id for ticket in affected]
        )
    )