symfonic.memory.backends¶
backends ¶
Backend implementations for graph and vector storage.
The Postgres backends import asyncpg (and pgvector) at module level
behind a try/except ImportError guard. Exporting them eagerly here made
that guard run for every import of anything under symfonic.memory --
including import symfonic.memory itself -- so a deployment with no
Postgres still asked the interpreter for asyncpg on every process start.
That is the "disabled integrations are not imported or probed" clause of
story s4-2, and rule IA-5 of the integration-adapter standard (T4.2.1).
They are therefore resolved through a module __getattr__ (PEP 562):
from symfonic.memory.backends import PostgresGraphBackend still works and
still returns the same class, but the import cost is paid by the caller that
asks for it. The in-memory backends stay eager -- they cost nothing.
InMemoryGraphBackend ¶
GraphBackend over dicts keyed by (tenant_id, node_id/edge_id).
Testing and local development only.
Source code in symfonic/memory/backends/in_memory.py
add_edge
async
¶
Add an edge to the in-memory store.
add_node
async
¶
Add a node to the in-memory store.
v8.0: stamps the materialised scope_path into the node's
property bag (zero-migration) so the prefix-isolation filter (§5.d)
can be enforced on every read.
Source code in symfonic/memory/backends/in_memory.py
delete_edge
async
¶
Delete a single edge by its ID.
delete_node
async
¶
Delete a node, optionally cascading to connected edges.
Source code in symfonic/memory/backends/in_memory.py
delete_subtree
async
¶
Erase owned subtree, edges and cursors in one non-yielding operation.
Source code in symfonic/memory/backends/in_memory.py
get_neighbors
async
¶
get_neighbors(scope: TenantScope, node_id: NodeId, relationship: str | None = None) -> list[MemoryNode]
Get nodes connected to the given node by edges.
Source code in symfonic/memory/backends/in_memory.py
get_node
async
¶
Get a node by ID within the scope's visible prefix chain.
Source code in symfonic/memory/backends/in_memory.py
query_edges
async
¶
query_edges(scope: TenantScope, filters: dict[str, Any] | None = None, limit: int = 50, offset: int = 0) -> list[MemoryEdge]
Query edges with optional relationship filter and pagination.
Source code in symfonic/memory/backends/in_memory.py
query_nodes
async
¶
query_nodes(scope: TenantScope, filters: dict[str, Any], limit: int | None = 50) -> list[MemoryNode]
Filter nodes by properties within the tenant scope.
limit=None returns every matching node (no truncation);
limit=0 returns no rows (v8.7.1 — unified across backends).
Source code in symfonic/memory/backends/in_memory.py
query_subtree
async
¶
query_subtree(scope: TenantScope, filters: dict[str, Any], limit: int | None = None) -> list[MemoryNode]
Filter nodes at scope or below it (the descendant read).
Source code in symfonic/memory/backends/in_memory.py
transaction ¶
All of a batch, or none of it -- see :mod:.in_memory_transaction.
traverse
async
¶
BFS traversal from start node up to max_depth.
Source code in symfonic/memory/backends/in_memory.py
update_node
async
¶
Partial update of node properties.
Source code in symfonic/memory/backends/in_memory.py
upsert_edge
async
¶
Insert edge or increment weight by 1 if (tenant, source, target, rel) matches.
Source code in symfonic/memory/backends/in_memory.py
InMemoryVectorBackend ¶
In-memory implementation of the VectorBackend protocol.
Stores embeddings in a list and uses brute-force cosine similarity for search. Suitable for testing only.
Source code in symfonic/memory/backends/in_memory_vector.py
add
async
¶
add(scope: TenantScope, ids: list[str], embeddings: list[list[float]], metadatas: list[dict[str, Any]], documents: list[str]) -> None
Add vectors with metadata to the store.
v8.0: stamps the materialised scope_path into the metadata bag so
the prefix-isolation filter (§5.d) can run on every search.
Source code in symfonic/memory/backends/in_memory_vector.py
count
async
¶
delete
async
¶
Delete vectors by their IDs.
Source code in symfonic/memory/backends/in_memory_vector.py
search
async
¶
Brute-force cosine similarity search.
v8.0: ALWAYS-ON prefix-isolation (§5.d) — only entries whose stored
scope_path is a prefix of the query path are scored. Dual-reads
pre-v8.0 metadata (no scope_path) as a 1-level root path.
Source code in symfonic/memory/backends/in_memory_vector.py
PostgresGraphBackend ¶
GraphBackend backed by PostgreSQL with JSONB storage.
All operations enforce tenant isolation via WHERE tenant_id = $N.
Source code in symfonic/memory/backends/postgres_graph.py
pool
property
¶
The pool this backend speaks through, and so its transaction domain.
Public because the consolidation commit has to prove that the graph, the lease and the staged records share one domain before it will call a cycle atomic. A proof that read a private attribute would be a proof about this class's spelling.
backfill_scope_paths
async
¶
Eagerly backfill NULL scope_path rows (design §6.b migration).
Pre-v8.0 rows have scope_path IS NULL; they read as a 1-level root
path tenant\x1f<tenant_id> via the query-time dual-read, so this
eager backfill is OPTIONAL (online-safe, idempotent) — it just
materialises the same value into the column so the B-tree index and
the scorer's dual-read agree. Returns the number of rows updated.
Uses a parameterised expression so adopter tenant_ids cannot inject.
Source code in symfonic/memory/backends/postgres_graph.py
delete_subtree
async
¶
Erase scope and every descendant scope in one statement.
A data-modifying CTE, not two round trips and not a client-side scan: one statement is one implicit transaction, so a write that lands in a descendant scope while this runs is either wholly before the sweep's snapshot (and erased) or wholly after it. The enumerate-then-delete shape has a window between those two facts, and the operation that opens it is a tenant deletion running while other work drains.
The predicate is the delimiter-terminated descendant sweep from
:mod:symfonic.memory.subtree — the one place a LIKE on a scope
path is correct, and only because the delimiter is appended. Without
it, forgetting acme would take acmecorp with it.
Edges go if either endpoint went, or if the edge itself sits in the subtree; the same two placeholders serve both halves.
Source code in symfonic/memory/backends/postgres_graph.py
ensure_schema
async
¶
Create graph tables (nodes + edges) and their indexes.
Does NOT create the memory_vectors table — that is the
responsibility of :class:PostgresVectorBackend.ensure_schema.
Source code in symfonic/memory/backends/postgres_graph.py
query_edges
async
¶
query_edges(scope: TenantScope, filters: dict[str, Any] | None = None, limit: int = 50, offset: int = 0) -> list[MemoryEdge]
Query edges directly from memory_edges table with pagination.
Supports optional relationship filter. All other keys in filters
are ignored to avoid SQL injection risk.
Source code in symfonic/memory/backends/postgres_graph.py
query_subtree
async
¶
query_subtree(scope: TenantScope, filters: dict[str, Any], limit: int | None = None) -> list[MemoryNode]
Query nodes at scope or below it (the descendant read).
Identical to :meth:query_nodes but for the scope predicate: the
delimiter-terminated descendant sweep in place of the ancestor
exact-IN. Sharing the body is deliberate — the two reads must agree
about layer, label, ordering and truncation, and the only thing that
may differ between them is which direction of the hierarchy they mean.
Source code in symfonic/memory/backends/postgres_graph.py
query_subtree_page
async
¶
query_subtree_page(scope: TenantScope, filters: dict[str, Any], *, limit: int, offset: int) -> list[MemoryNode]
One native descendant page without reading every prior page.
GraphAdminService accepts offsets for its browser contract. Its
compatibility fallback must fetch through offset + limit because
the historical backend protocol has no offset; Postgres does, and at
volume that fallback turns page fifty into a ten-thousand-row process
allocation. Keep the optional seam on the concrete adapter until the
backend protocol can add it without breaking adopters.
Source code in symfonic/memory/backends/postgres_graph.py
traverse
async
¶
BFS graph traversal up to max_depth hops from start node.
Source code in symfonic/memory/backends/postgres_graph.py
upsert_edge
async
¶
Insert edge or increment weight by 1 if the same edge already exists.
Uniqueness is determined by (tenant_id, source, target, relationship).
The unique index idx_memory_edges_upsert must exist for ON CONFLICT
to resolve correctly (created by ensure_schema).
Source code in symfonic/memory/backends/postgres_graph.py
PostgresLeases ¶
A :class:~symfonic.capabilities.memory.leases.LeasePort over Postgres.
Source code in symfonic/memory/backends/postgres_leases.py
pool
property
¶
The pool this port speaks through, and so its transaction domain.
Public because the commit path has to prove that the lease, the graph and the staged records all live in one domain before it will call a cycle atomic. Reading that off a private attribute would make the proof depend on an implementation detail of this class.
ensure_schema
async
¶
Create the lease table if it is not there.
IF NOT EXISTS is idempotent across time and not across
concurrency: two sessions running it at the same instant both pass the
existence check and both insert into pg_type, and one gets a
duplicate-key error. Ordinarily an obscure wrinkle -- here it is the
expected case, because the workers this table exists to coordinate are
exactly the ones that start together.
So a failure is retried once against a table that exists by then, and only then re-raised. Caught by the first run of the cross-process test and by nothing else: every later run found the table already there.
Source code in symfonic/memory/backends/postgres_leases.py
hold_for_update
async
¶
Whether lease is ours, and keep it ours until this transaction ends.
The commit-time fence. :meth:holds answers a question about the past
tense the moment it returns; this one takes the row lock, so a rival
acquisition queues behind the caller's transaction instead of landing
between the answer and the write it authorised.
Only meaningful inside a transaction -- outside one the lock is released
with the statement and this is :meth:holds with extra cost. The commit
path always calls it inside one; see
:func:symfonic.capabilities.memory.commit.commit_cycle, which refuses
to run without a transaction domain rather than calling this and
pretending.
Source code in symfonic/memory/backends/postgres_leases.py
PostgresPoolManager ¶
Async connection pool manager wrapping asyncpg.Pool.
Usage::
pool = PostgresPoolManager(dsn="postgresql://user:pw@host/db")
await pool.open()
async with pool as conn:
await conn.execute("SELECT 1")
await pool.close()
Or as a context manager for the pool itself::
async with PostgresPoolManager(dsn=...) as pool:
conn = await pool.acquire()
try:
await conn.execute("SELECT 1")
finally:
await pool.release(conn)
Source code in symfonic/memory/backends/pool.py
dsn
property
¶
Return the configured connection string (read-only).
Used by the v7.1.1 PostgresCheckpointerFactory to build a
separate psycopg pool against the same database. The two pools
intentionally do not share connections (asyncpg vs. psycopg
protocols are incompatible inside langgraph-checkpoint-postgres).
acquire
async
¶
Acquire a connection from the pool.
Caller is responsible for releasing via :meth:release.
Inside :meth:transaction this hands back that transaction's own
connection, so every backend on this pool joins it without knowing it
exists (:mod:.pool_transaction).
Source code in symfonic/memory/backends/pool.py
close
async
¶
open
async
¶
Create the underlying asyncpg connection pool.
Passes init=_register_pgvector_on_connection so every new
physical connection registers the pgvector binary codec before
the pool hands it out. See module docstring for the rationale.
Source code in symfonic/memory/backends/pool.py
release
async
¶
Return a connection to the pool.
A no-op for a connection :meth:transaction holds: releasing it
mid-way would hand a half-written batch to somebody else.
Source code in symfonic/memory/backends/pool.py
transaction ¶
Run a block so every operation on this pool commits or fails together.
Ambient rather than passed: :mod:.pool_transaction has the why.
PostgresVectorBackend ¶
PostgresVectorBackend(pool: PostgresPoolManager, embedding_dim: int = 1536, *, search_strategy: Literal['exact', 'approximate'] = 'exact')
VectorBackend backed by PostgreSQL + pgvector.
Uses cosine distance (<=> operator) for similarity search.
All operations enforce tenant isolation via WHERE tenant_id = $N.
search_strategy='exact' ranks the authorized subset: bounded returned
rows, but O(visible vectors) database distance work. 'approximate' opts
into HNSW, which can underfill or miss neighbours after scope filtering.
Source code in symfonic/memory/backends/postgres_vector.py
pool
property
¶
The pool this backend speaks through, and so its transaction domain.
count
async
¶
Return the total number of stored vectors for the tenant.
Source code in symfonic/memory/backends/postgres_vector.py
ensure_schema
async
¶
Create the memory_vectors table and indexes if not present.