Skip to content

symfonic.core.prompt.blocks.protocol_conflict

protocol_conflict

Optimistic-concurrency vocabulary for writable block sources.

Split out of :mod:symfonic.core.prompt.blocks.protocol (441 lines against the 300-line budget). protocol describes what a block source can do -- three widening capability Protocols, checked structurally. This module is the one thing a writable source must agree with its caller about beyond those method signatures: what happens when the head moved underneath an append.

RevisionConflictError carries the two heads, is picklable across a process boundary through _rebuild_revision_conflict_error, and ensure_expected_head is the single place the comparison is spelled out so two adapters cannot disagree about it.

protocol re-exports both names.

RevisionConflictError

RevisionConflictError(*, scope_path: str, block_id: str, expected_head: str | None, actual_head: str | None)

Bases: ConflictError

An append was attempted against a head the caller no longer holds.

Raised by :meth:WritableBlockSource.append_revision when expected_head does not match the block's current head revision -- another writer appended in between. The write is rejected; it is never applied on top of the newer revision, because doing so would silently discard the concurrent edit while leaving the history looking linear.

It derives from :class:~symfonic.core.protocols.ConflictError (and so from StorageError) deliberately: an optimistic-lock failure is the same concept whether it is detected by :func:ensure_expected_head or by the backing store's own unique constraint. A caller mapping except ConflictError to a 409 must catch both paths, or the pre-check path would surface as a 500.

Source code in src/symfonic/core/prompt/blocks/protocol_conflict.py
def __init__(
    self,
    *,
    scope_path: str,
    block_id: str,
    expected_head: str | None,
    actual_head: str | None,
) -> None:
    self.scope_path = scope_path
    self.block_id = block_id
    self.expected_head = expected_head
    self.actual_head = actual_head
    super().__init__(
        f"cannot append to block {block_id!r} at scope_path {scope_path!r}: "
        f"caller expected head {expected_head!r} but the current head is "
        f"{actual_head!r}; the block changed underneath the caller, so the "
        "append is rejected rather than layered on top of an unseen revision"
    )

ensure_expected_head

ensure_expected_head(scope: TenantScope, block_id: str, *, expected_head: str | None, actual_head: str | None) -> None

Raise :class:RevisionConflictError unless the heads agree.

The shared optimistic-concurrency check for :meth:WritableBlockSource.append_revision implementations, so every adapter rejects a stale write the same way instead of each inventing its own (or, worse, overwriting). It sits on top of the backing store's own unique-sequence constraint, it does not replace it.

Source code in src/symfonic/core/prompt/blocks/protocol_conflict.py
def ensure_expected_head(
    scope: TenantScope,
    block_id: str,
    *,
    expected_head: str | None,
    actual_head: str | None,
) -> None:
    """Raise :class:`RevisionConflictError` unless the heads agree.

    The shared optimistic-concurrency check for
    :meth:`WritableBlockSource.append_revision` implementations, so every
    adapter rejects a stale write the same way instead of each inventing
    its own (or, worse, overwriting). It sits on top of the backing
    store's own unique-sequence constraint, it does not replace it.
    """
    if expected_head != actual_head:
        raise RevisionConflictError(
            scope_path=scope.scope_path,
            block_id=block_id,
            expected_head=expected_head,
            actual_head=actual_head,
        )