Skip to content

store-owned: finish Protocol-B removal (PG-free zero-batch tracking) - #3669

Closed
nicoloboschi wants to merge 13 commits into
mainfrom
store-owned-drop-cross-store-txn
Closed

store-owned: finish Protocol-B removal (PG-free zero-batch tracking)#3669
nicoloboschi wants to merge 13 commits into
mainfrom
store-owned-drop-cross-store-txn

Conversation

@nicoloboschi

Copy link
Copy Markdown
Collaborator

What

Completes the store-owned / PG-free write path so a store that owns retain, entities, and document
metadata (store_owned_retain_for(bank_id) / owns_document_store_for(bank_id)) no longer goes
through cross-store write-group (Protocol-B) transactions for any operation.

Consolidation, curation edits, unit deletes, delete-document/where, delta re-ingest, the main
streaming write, and the per-batch 0-fact path were already converted in the preceding commits. This
adds the one remaining gap:

The fix — zero-batch document tracking

_streaming_retain_batch's post-loop "no batch processed" finalizer created a Postgres documents
row and opened a begin_txn / decide_txn write-group even for a store-owned retain. It is
reached when a retain yields zero batches:

  • empty or gibberish content (no facts extracted at all), or
  • a recovery where every chunk was already committed on a prior attempt.

In that window the per-batch 0-fact branch's store-owned path never runs, so doc_tracking_done
stays False and the finalizer falls through to the legacy Postgres path. That reintroduces both a
Postgres write and an undecided write-group on the supposedly PG-free path.

The finalizer now takes the same store-owned branch the per-batch 0-fact path uses: for a
store-owned retain, drop the document's prior memories with one plain store-side
delete-by-document — no Postgres row, no write-group, nothing left undecided to stall the store's
indexer.

Also

A comments-only commit describes the store-owned delete generically (drops an internal backend name
from three comments); no behavioural change.

Status

Draft — not for merge yet. Opened to make the completed store-owned series reviewable against
main and to continue the remaining work here.

Test notes

  • orchestrator.py and memory_engine.py parse clean.
  • The finalizer change mirrors the reviewed per-batch 0-fact store-owned branch (same guard, same
    delete_document(conn=None, ...) shape).
  • Still to run here: the retain zero-batch / recovery paths and the store-owned e2e suite.

A memories store that owns its rows (external backend) retains a document in a
connection-free store phase. That phase wrote each memory TWICE: insert_facts
staged it without entities, then record_unit_entities read the just-written
records back (full vectors) and re-upserted them with entity ids attached —
because entity ids can only be resolved onto real unit ids after they exist.

On an object-store-backed engine that reattach is a second full write per memory
plus a read-back, doubling the round-trips on the slow path (the dominant cost of
retain). It's avoidable: mint the ids without writing (insert_facts_batch with
defer_index), remap the entities, then write once via index_facts with the entity
ids already inline — tagged with the write-group txn so it commits atomically with
the group (and, as a bonus, the postings are now witness-covered instead of riding
an uncovered seam). Co-occurrence accumulation still runs (record_unit_entity_postings
gains store_write=False: co-occurrence only, since the row is already correct).

Streaming ext path only; the delta path is unchanged. Tests updated: the single
write via index_facts is asserted connection-free and txn-tagged, and the posting
runs store_write=False. No behavior change for the Postgres store (a no-op there).
A store advertising `store_owned_retain` now commits the entire retain in ONE
server-side call: it resolves each fact's raw entity names against its own
registry, mints ids for new names, writes the memories with ids attached, and
replaces the document's prior version atomically. The orchestrator therefore:

- skips Phase-1 entity resolution (no Postgres trigram scan / entity INSERTs);
- routes the streaming write through a new `_streaming_store_owned_retain` that
  reconstructs entity names from the facts and issues the single retain — no
  connection phase, no `documents`/`chunks`/`entities` rows, no commit witness,
  no `decide_txn`. The store's single write is already atomic;
- marks the document tracked so the post-loop finalizer (which would write a
  Postgres documents row and run handle_document_tracking, whose delete-by-
  document_id lands at a later seq and would delete the just-written memories)
  does not fire;
- falls delta retain back to full retain for such a store (delta's chunk-diff
  still rides a Postgres connection phase) — a follow-up.

Non-store-owned (Postgres) backends are unchanged: the capability gate defaults
off, so the two-phase path runs exactly as before.

Verified end-to-end (store-owned backend, LLM-free chunks): retain 3 docs ->
recall 3/3 ranked; zero Postgres rows for the bank; an entity shared by two
documents resolves to ONE id; two writes per document, no clobbering tombstones.
…t per-org

The store-owned retain path was gated on a plain `store_owned_retain` attribute,
which a routing memories extension (per-org: some banks on a store, some on
Postgres) does not expose — so a routed store-owned bank silently fell back to
the Postgres path. Add `store_owned_retain_for(bank_id)` on the base extension
(defaults to the class attribute, mirroring owns_document_store_for) and consult
it from the three retain gates instead of the attribute. A single-store extension
needs no change; a router overrides the _for method to answer per bank.
…gate it

RoutingMemories generates a delegating wrapper for every async method on the
MemoriesExtension interface. The store-owned retain() lived only on the concrete
store, so a routed org hit 'RoutingMemories has no attribute retain'. Declare it
on the base (default raises NotImplementedError; store-owned stores override, and
the orchestrator only calls it when store_owned_retain_for is true) so the router
covers it automatically.
…ix include_chunks

Two write paths still opened a cross-store write-group for a store-owned org even
though every memory write in them lands ONLY in the store:

- Consolidation (consolidator.py): minted a write-group per LLM batch and left it
  pending if the batch crashed/cancelled between mint and decide — the exact source
  of the undecided txn+upsert/txn+patch ops that stall the store's indexer and
  starve every namespace's fold. For a store-owned org the observations, deletes and
  mark_consolidated stamps are store-only, so _batch_txn=None: plain, immediately-
  durable, idempotent-on-retry writes, no witness, nothing left undecided. The
  refresh-tag bookkeeping becomes a best-effort plain write.
- delete_memory_unit (memory_engine.py): the write-group wrapped only the store
  tombstone (the relink/prune enqueues join memory_units/unit_entities, which a
  store-owned org keeps no rows in — no-ops); a plain delete needs no group.

Also fix include_chunks for store-owned recall: it seeded chunk metadata from the
Postgres chunks table, which PG-free retain no longer writes, so no chunks came
back. Derive the metadata from the hits (chunk_id + document_id) and overlay the
text from the store — the store path that was already half-wired.

Curation, delete-document and 0-fact retain tracking still use a write-group: they
genuinely write the Postgres entities registry / documents row alongside the store,
so they stay until those move off Postgres.
…hunks is empty

PG-free retain writes no SQL chunks row, so include_chunks came back empty for a
store-owned org. Query the chunks table as before; if the store owns its document
store AND the query is empty, synthesize each row from the chunk_id itself
(chunk_id = {bank}_{document}_{index}; bank_id known, index is the last segment),
then overlay the text from the store. Chunk metadata now returns; the text overlay
(list_chunk_texts) is exercised the same way as the legacy owns-docs path.
…sh Protocol-B removal)

The last three write-group txn users for a store-owned org, all of which depended
on Postgres rows that PG-free retain no longer writes:

- delete_document: gated the store tombstone on the Postgres documents DELETE
  RETURNING id, which is always None under PG-free retain (no row) — so a
  store-owned document could NEVER be deleted (memories + doc record left behind).
  Now drives the deletion off the store: plain delete-by-document + doc-record
  delete, no write-group (store-only).
- curation (edit/invalidate/revert): _curation_txn=None for store-owned — the
  edited memory write is atomic on its own; the Postgres entity/posting writes are
  unused by store-owned reads. No witness left undecided.
- 0-fact retain tracking: a store-owned 0-fact (re-)ingest now does one plain
  delete-by-document instead of a Postgres documents row + write-group.

With retain, delta, consolidation, unit-delete, delete-document, curation and
0-fact tracking all txn-free, a store-owned org opens NO Protocol-B write-group at
all — nothing can be left undecided to stall the indexer.
A store that owns its document metadata keeps no rows in the SQL documents table,
so list_documents returned an empty page for it. Add a store-owned branch that
delegates to the store's own list_documents (returning the same {items,total,
limit,offset} shape), and declare list_documents on the MemoriesExtension base so
routing delegates it. Store-generic.
Bank stats read total_documents from the SQL documents table unconditionally, so a
store that owns its documents (empty SQL table) reported 0 — the one count not
asked of the store, unlike links/nodes. Add a store-owned branch calling the
store's count_documents, and declare count_documents on the base. Store-generic.
get_entity_graph read the SQL entity_cooccurrences/entities tables, empty for a
store that owns its entities. Add a store-owned branch delegating to the store's
get_entity_graph, and declare it on the base. Store-generic.
When a store owns the whole retain (its own entity registry, atomic writes), a
curation edit that changes entities was still minting the new entities into the
host SQL 'entities' table (resolve_entities_only autocommit) and writing the
unit_entities postings (reassert_entities_batch / link_units_to_entities_batch) —
rows that store's own reads never consult. So a brand-new entity created by an
edit landed only in SQL and was invisible to the store's entity reads.

Route it the same way retain already does: hand the store the raw entity NAMES
and let its apply_edit resolve + mint them against its OWN registry, rewriting the
memory's entity ids from the result. apply_edit gains an 'entity_names' argument
(authoritative when set; a SQL-registry store ignores it and uses the pre-resolved
entity_ids). For such a store the edit path now skips the SQL entity mint, the
canonical-name read-back, and the clear/reassert/link/prune postings entirely.

No behavior change for a SQL store (entity_names stays None; the resolve+relink
path is untouched). Companion: a store provider's apply_edit must resolve+mint
entity_names. (get_entity_graph, the other store-owned entity residue, already
reads from the store.)
…removal)

The post-loop "no batch processed" finalizer created a Postgres documents row and
opened a write-group transaction even for a store that owns retain. It is reached
when a retain yields zero batches — empty/gibberish content, or a recovery where
every chunk was already committed on a prior attempt — so the per-batch 0-fact
branch's store-owned path never ran. Guard it the same way: for a store-owned
retain, drop the document's prior memories with one plain store-side
delete-by-document, no Postgres row and no write-group, so nothing is left
undecided to stall the store's indexer.
@nicoloboschi
nicoloboschi force-pushed the store-owned-drop-cross-store-txn branch from c2926d2 to 62336b4 Compare August 20, 2026 12:31
@nicoloboschi

Copy link
Copy Markdown
Collaborator Author

Superseded by #3696, which consolidates the store-owned writes, the delta re-ingest CAS, and the PG-free zero-batch finalizer into one branch so the store interface and its implementers version together.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant