symfonic.capabilities.memory.writes¶
writes ¶
Deliverable 2: the coordinator between a produced memory and a stored one.
Three jobs, and each one exists because the shipped path does it wrong in a way that only shows up under load or under failure:
- Background writes are owned.
core/runtime.pyandagent/engine.pyeach hold a module-level_background_tasksset. A write detached there outlives every run that added to it — nothing waits for it, nothing reports it, andengine.pyneeds anatexithook to warn that work nobody owned may never have run. T2.3.4 replaced that with a registry that refuses unattributed work; this coordinator spawns through it, or refuses to spawn. - Flush waits for what it commits. A flush that races an in-flight write
publishes half a turn: the memories that happened to land are retrievable and
the rest silently are not. :meth:
MemoryWriteCoordinator.flushjoins its own background writes first. - A lost flush does not lose the write. When the store is unreachable the
receipt is
degradedand the pending bookkeeping stays, so the next flush still has something to commit. Clearing it would turn one transport blip into permanent memory loss with no error anywhere.
Rollback is the pending buffer. Per the port contract, a written memory is not
retrievable until it is flushed, so a turn that ends badly abandons its scope
and nothing it produced was ever visible — really so where the adapter
implements :class:~.work.MemoryDiscardPort, and reported as unenforced where
it does not.
The two borrowed surfaces (owned background work, optional pending-discard) and
the per-write outcome live in :mod:.work.
MemoryWriteCoordinator ¶
MemoryWriteCoordinator(*, writes: MemoryWritePort, lifecycle: MemoryLifecyclePort, background: BackgroundWorkPort | None = None, deadline_seconds: float | None = None)
Owns the write side of a turn: foreground, background, and flush.
Source code in src/symfonic/capabilities/memory/writes.py
lifecycle
property
¶
What publishes a staged record, and so its transaction domain.
Published so the consolidation commit can establish that the records it flushes land in the same domain as the graph mutations applied beside them; without that the two halves could not be one commit.
pending_ids
property
¶
Every uncommitted record id this coordinator wrote, sorted.
pending_scopes
property
¶
Scope paths holding written-but-uncommitted memories, sorted.
write_port
property
¶
The participant that stages records, for transaction-domain validation.
abandon
async
¶
Give up on scope's uncommitted memories without committing them.
The turn's rollback. Nothing committed is touched — a forget
would take the previous turns' memories along with this one's.
When the bound lifecycle port implements :class:MemoryDiscardPort the
pending buffer is dropped at the store and the rollback is real. When
it does not, the receipt comes back degraded: this coordinator will
not commit those memories, but nothing stops another flush of the same
scope from doing so, and saying otherwise would be a rollback that only
exists in the caller's head.
Source code in src/symfonic/capabilities/memory/writes.py
flush
async
¶
flush(scope: MemoryScope, *, join: bool = True, required_ids: tuple[str, ...] = ()) -> LifecycleReceipt
Commit scope and everything below it.
join awaits this coordinator's in-flight background writes first,
because a flush that overtakes its own write commits half a turn.
required_ids rejects incomplete publication before forgetting pending
bookkeeping; an atomic caller can then roll back the whole transaction.
Source code in src/symfonic/capabilities/memory/writes.py
join
async
¶
Await every background write spawned since the last join.
A write that raised is reported, not re-raised: the failure belongs to the write, and a flush that exploded because a memory did not land would end the turn over the thing that was supposed to be optional.
Source code in src/symfonic/capabilities/memory/writes.py
write
async
¶
Write request now, degrading rather than failing the turn.
A ScopeViolation is not caught: a tenant boundary crossing is not
a degraded turn, and a write that quietly reported degraded for one
would hide the single failure isolation exists to surface.
Source code in src/symfonic/capabilities/memory/writes.py
write_in_background ¶
Spawn request as run-owned work, or refuse to spawn it at all.
There is no third path. A coordinator with no registry that fell back
to asyncio.create_task would recreate the detached-set problem the
registry exists to end, and it would do it invisibly.