Skip to content

symfonic.memory.pending

pending

Unpublished-write marker contract (namespaced, framework-reserved keys).

The write/flush split has no legacy equivalent: commit_pending writes and the row is live. An in-process adapter can keep unpublished records in a side buffer no reader can see, but a persistent adapter has no such place — the rows are already in the store, so "pending" has to be a property of a row that the legacy reader also reads. This module is that representation, stated once so two adapters cannot pick differently.

Why durability="transient" and not a new marker of our own. The legacy reader already hides these rows: core.learning.durability_gate gates {"transient", "expired"} out of every promotion phase. No other value has that property, and a marker the legacy path does not honour would make the rollback story fiction — a mid-turn rollback would surface memories the turn never published to whichever side read them first. So a pending row is written transient because that is the word legacy already understands.

What transient does not mean, and the catch that follows. transient also carries a lifecycle: phases_maintenance.cleanup_working_ttl applies a 1h implicit TTL floor and phases_semantic_expiry soft-retracts on expiry. An unflushed write left to those phases would be garbage-collected on a timer, and a later flush would land on an already-retracted row. Pending is resolved by flush or by discard, never by a clock — so both phases skip a row carrying :func:is_pending, and the exemption is part of this contract rather than a courtesy of theirs.

That is also why the batch identifier exists. Pending rows are not ordinary transient memories, and after a crash somebody has to decide what each one was for; a row that only says "transient" cannot be told apart from a legitimately short-lived fact written by the legacy path. :data:PENDING_BATCH_KEY names the write batch, and :mod:symfonic.memory.reconcile is what reads it.

Keys are namespaced for the same reason :mod:symfonic.memory.retraction's are: properties is an open, caller-writable bag, and a domain fact that happens to be about something pending must not become framework control state.

is_pending

is_pending(properties: Mapping[str, Any] | None) -> bool

Whether properties marks a written-but-unpublished row.

The single predicate every reader, expiry phase and sweep must call — never an inline properties.get(...) — so the contract has one place to drift.

Source code in src/symfonic/memory/pending.py
def is_pending(properties: Mapping[str, Any] | None) -> bool:
    """Whether ``properties`` marks a written-but-unpublished row.

    The single predicate every reader, expiry phase and sweep must call — never
    an inline ``properties.get(...)`` — so the contract has one place to drift.
    """
    return bool((properties or {}).get(PENDING_KEY))

is_quarantined

is_quarantined(properties: Mapping[str, Any] | None) -> bool

Whether a pending row's outcome could not be determined.

Quarantine is a state within pending, never a state after it: a quarantined row stays hidden and stays exempt from expiry.

Source code in src/symfonic/memory/pending.py
def is_quarantined(properties: Mapping[str, Any] | None) -> bool:
    """Whether a pending row's outcome could not be determined.

    Quarantine is a state *within* pending, never a state after it: a
    quarantined row stays hidden and stays exempt from expiry.
    """
    return bool((properties or {}).get(QUARANTINED_KEY))

pending_properties

pending_properties(properties: Mapping[str, Any] | None = None, *, batch_id: str, at: datetime | None = None) -> dict[str, Any]

Return properties with the pending markers stamped on.

Takes the whole bag and returns a whole bag. The backends' update_node replaces properties wholesale when that key is supplied, so a caller that assembled only the delta would drop every unrelated property on the row — including the scope_path the isolation filter reads.

Source code in src/symfonic/memory/pending.py
def pending_properties(
    properties: Mapping[str, Any] | None = None,
    *,
    batch_id: str,
    at: datetime | None = None,
) -> dict[str, Any]:
    """Return ``properties`` with the pending markers stamped on.

    Takes the whole bag and returns a whole bag. The backends' ``update_node``
    replaces ``properties`` wholesale when that key is supplied, so a caller
    that assembled only the delta would drop every unrelated property on the
    row — including the ``scope_path`` the isolation filter reads.
    """
    bag = dict(properties or {})
    bag[DURABILITY_KEY] = DURABILITY_TRANSIENT
    bag[PENDING_KEY] = True
    bag[PENDING_BATCH_KEY] = batch_id
    bag[PENDING_AT_KEY] = (at or datetime.now(UTC)).isoformat()
    return bag

published_properties

published_properties(properties: Mapping[str, Any] | None) -> dict[str, Any]

Return properties promoted to durable, with every marker removed.

Removal is the point. A published row that kept durability="transient" would be hidden from the promotion phases it just became eligible for, and one that kept :data:PENDING_KEY would stay exempt from expiry forever.

Source code in src/symfonic/memory/pending.py
def published_properties(properties: Mapping[str, Any] | None) -> dict[str, Any]:
    """Return ``properties`` promoted to durable, with every marker removed.

    Removal is the point. A published row that kept ``durability="transient"``
    would be hidden from the promotion phases it just became eligible for, and
    one that kept :data:`PENDING_KEY` would stay exempt from expiry forever.
    """
    bag = {
        key: value
        for key, value in dict(properties or {}).items()
        if key not in _MARKER_KEYS
    }
    bag[DURABILITY_KEY] = DURABILITY_DURABLE
    return bag

quarantined_properties

quarantined_properties(properties: Mapping[str, Any] | None, *, reason: str) -> dict[str, Any]

Return properties with the quarantine marker and its reason.

Stays pending: the row keeps :data:PENDING_KEY and stays transient, so it is invisible to every reader and exempt from every clock. Quarantine records that nobody could decide, which is not the same as deciding.

Source code in src/symfonic/memory/pending.py
def quarantined_properties(
    properties: Mapping[str, Any] | None, *, reason: str
) -> dict[str, Any]:
    """Return ``properties`` with the quarantine marker and its reason.

    Stays pending: the row keeps :data:`PENDING_KEY` and stays transient, so it
    is invisible to every reader and exempt from every clock. Quarantine
    records that nobody could decide, which is not the same as deciding.
    """
    bag = dict(properties or {})
    bag[QUARANTINED_KEY] = True
    bag[QUARANTINE_REASON_KEY] = reason[:200]
    return bag