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 ¶
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
is_quarantined ¶
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
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
published_properties ¶
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
quarantined_properties ¶
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.