Skip to content

Commit fdbafe5

Browse files
committed
feat(backends): observable maintenance hooks + pgvector indexed-build path (RFC 001, #1725)
Adds the maintenance contract RFC 001 specifies but #1679 deferred, and gives pgvector an opt-in HNSW index path with concurrency-safe builds. - base.py: MaintenanceResult (status ran/already_running/noop + free-form stats), UnsupportedMaintenanceKindError, BaseBackend.maintenance_kinds ClassVar (reserved: analyze/compact/reindex; a backend with no analogue MUST omit, not no-op), and BaseCollection.maintenance_state()/run_maintenance() defaults. EmbeddingCollection forwards both (BaseCollection methods shadow __getattr__). - sqlite_exact: analyze (ANALYZE) + compact (VACUUM, autocommit + page stats); omits reindex (exact scan, no ANN index). maintenance_state reports row/page counts. - pgvector: reindex builds the optional HNSW index, serialized by a session-level pg_advisory_lock so concurrent daemon writers learn "already_running" instead of each stacking an ACCESS EXCLUSIVE build (the production wedge). It is opt-in: the default exact `<=>` scan is the 100%-recall path; an HNSW index trades exact recall for scale, so an operator invokes it deliberately. Also analyze; omits compact (autovacuum). Advertises supports_server_side_indexes. maintenance_state reports index presence. - qdrant/chroma: empty maintenance_kinds (qdrant self-optimizes; chroma maintenance is the separate repair CLI) — the faithful "omit" default. Tests: contract + sqlite (real, CI-runnable) + pgvector advisory-lock flow via a fake client (ran/noop/already_running, no live Postgres). Full suite green: 2488 passed, 82.47% coverage. Benchmark three-phase wiring is deferred — the existing benchmarks/ are task-benchmarks, not backend-comparison harnesses, so there is nothing to wire into yet. Closes #1725. Refs #743.
1 parent 13de7a6 commit fdbafe5

6 files changed

Lines changed: 524 additions & 1 deletion

File tree

mempalace/backends/__init__.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,11 +27,13 @@
2727
HealthStatus,
2828
LexicalHit,
2929
LexicalResult,
30+
MaintenanceResult,
3031
PalaceNotFoundError,
3132
PalaceRef,
3233
QueryResult,
3334
UnsupportedCapabilityError,
3435
UnsupportedFilterError,
36+
UnsupportedMaintenanceKindError,
3537
)
3638
from .chroma import ChromaBackend, ChromaCollection
3739
from .pgvector import PgVectorBackend, PgVectorCollection
@@ -64,6 +66,7 @@
6466
"HealthStatus",
6567
"LexicalHit",
6668
"LexicalResult",
69+
"MaintenanceResult",
6770
"PalaceNotFoundError",
6871
"PalaceRef",
6972
"PgVectorBackend",
@@ -75,6 +78,7 @@
7578
"SQLiteExactCollection",
7679
"UnsupportedCapabilityError",
7780
"UnsupportedFilterError",
81+
"UnsupportedMaintenanceKindError",
7882
"available_backends",
7983
"detect_backend_for_path",
8084
"detect_backends_for_path",

mempalace/backends/base.py

Lines changed: 62 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
"""
1515

1616
from abc import ABC, abstractmethod
17-
from dataclasses import dataclass
17+
from dataclasses import dataclass, field
1818
from typing import ClassVar, Optional, Protocol, runtime_checkable
1919

2020

@@ -62,6 +62,15 @@ class UnsupportedCapabilityError(BackendError):
6262
"""Raised when a backend does not implement an optional capability."""
6363

6464

65+
class UnsupportedMaintenanceKindError(BackendError):
66+
"""Raised when ``run_maintenance(kind)`` is called with an unadvertised kind.
67+
68+
A backend MUST advertise a kind in ``maintenance_kinds`` before it accepts
69+
it (RFC 001). Advertising a kind it does not implement is a conformance
70+
failure; a kind it has no analogue for MUST be omitted, not no-op'd.
71+
"""
72+
73+
6574
class BackendMismatchError(BackendError):
6675
"""Raised when a selected backend does not match existing palace artifacts."""
6776

@@ -140,6 +149,29 @@ class EmbedderIdentity:
140149
dimension: int = 0
141150

142151

152+
@dataclass(frozen=True)
153+
class MaintenanceResult:
154+
"""Observable outcome of ``run_maintenance(kind)`` (RFC 001).
155+
156+
Maintenance is *not* fire-and-forget: a backend MUST serialize concurrent
157+
same-kind runs and report the outcome so a caller can learn it must not
158+
re-trigger. ``status`` is one of:
159+
160+
* ``"ran"`` — this call performed the maintenance.
161+
* ``"already_running"`` — another caller holds the work; this call did
162+
nothing and the caller MUST NOT re-trigger (the production index-build
163+
wedge: concurrent writers each issuing the build stacked exclusive locks).
164+
* ``"noop"`` — nothing needed doing (e.g. the index already exists).
165+
166+
``stats`` is free-form per kind (rows analyzed, bytes reclaimed, index
167+
build time) for benchmark/operator reporting.
168+
"""
169+
170+
kind: str
171+
status: str
172+
stats: dict = field(default_factory=dict)
173+
174+
143175
@runtime_checkable
144176
class Embedder(Protocol):
145177
"""Minimal embedder contract (RFC 001, normative for identity checking).
@@ -436,6 +468,27 @@ def effective_embedder_identity(self) -> Optional[EmbedderIdentity]:
436468
"""
437469
return None
438470

471+
def maintenance_state(self) -> dict:
472+
"""Return a structured snapshot of this collection's maintenance state.
473+
474+
Free-form per backend (e.g. row count, whether a vector index exists,
475+
last-analyze age). Used by benchmark harnesses to record state
476+
alongside each latency/recall measurement so an un-analyzed store is
477+
not compared against a settled one (RFC 001). Defaults to empty.
478+
"""
479+
return {}
480+
481+
def run_maintenance(self, kind: str) -> "MaintenanceResult":
482+
"""Run a maintenance ``kind`` and return an observable result (RFC 001).
483+
484+
Backends advertise supported kinds in ``BaseBackend.maintenance_kinds``
485+
and override this. The default supports nothing, so every kind raises
486+
:class:`UnsupportedMaintenanceKindError`. Implementations MUST serialize
487+
concurrent same-kind runs and report ``already_running`` rather than
488+
stacking the work.
489+
"""
490+
raise UnsupportedMaintenanceKindError(f"backend does not support maintenance kind {kind!r}")
491+
439492
def lexical_search(
440493
self,
441494
*,
@@ -522,6 +575,14 @@ class BaseBackend(ABC):
522575
#: search converts distance→similarity off this declaration rather than
523576
#: assuming cosine. All in-tree backends are cosine today.
524577
distance_metric: ClassVar[str] = "cosine"
578+
#: Maintenance kinds this backend implements (RFC 001). Reserved names:
579+
#: ``"analyze"`` (refresh planner/query statistics), ``"compact"`` (reclaim
580+
#: space, rewrite storage), ``"reindex"`` (build/rebuild secondary indexes).
581+
#: A backend with no analogue for a kind MUST omit it rather than declare a
582+
#: no-op, so a benchmark harness can trust the set. Backends MAY add their
583+
#: own kinds. ``run_maintenance`` raises ``UnsupportedMaintenanceKindError``
584+
#: for anything not listed here.
585+
maintenance_kinds: ClassVar[frozenset[str]] = frozenset()
525586

526587
@abstractmethod
527588
def get_collection(

mempalace/backends/embedding_wrapper.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,12 @@ def set_embedder_identity(self, identity) -> None:
6969
def effective_embedder_identity(self):
7070
return self._inner.effective_embedder_identity()
7171

72+
def maintenance_state(self) -> dict:
73+
return self._inner.maintenance_state()
74+
75+
def run_maintenance(self, kind: str):
76+
return self._inner.run_maintenance(kind)
77+
7278
def add(self, *, documents, ids, metadatas=None, embeddings=None):
7379
documents = _as_list(documents)
7480
ids = _as_list(ids)

mempalace/backends/pgvector.py

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -352,6 +352,30 @@ def _quote_identifier(name: str) -> str:
352352
return '"' + name.replace('"', '""') + '"'
353353

354354

355+
# Session-level advisory-lock namespace for serializing HNSW index builds
356+
# across daemon writers (RFC 001). classid is a fixed mempalace constant
357+
# ("MEMP" in ASCII); objid is a stable per-table key. Both must fit a signed
358+
# int4, which ``pg_advisory_lock(int4, int4)`` requires.
359+
_MAINTENANCE_LOCK_CLASSID = 0x4D454D50 # "MEMP" — a positive, valid int4
360+
361+
362+
def _advisory_objid(table: str) -> int:
363+
"""Stable signed-int4 advisory key derived from the table name."""
364+
raw = int(sha256(table.encode("utf-8")).hexdigest()[:8], 16) # 0 .. 2**32-1
365+
return raw - 2**32 if raw >= 2**31 else raw
366+
367+
368+
def _hnsw_index_name(table: str) -> str:
369+
"""Deterministic, collision-safe index name for ``table``.
370+
371+
Routes through :func:`_pg_identifier`, which hashes the overflow when the
372+
name exceeds Postgres' 63-byte limit. A naive ``[:63]`` truncation could
373+
return the table name verbatim (tables and indexes share the ``pg_class``
374+
namespace), which would fail with "relation already exists".
375+
"""
376+
return _pg_identifier(f"{table}_hnsw_idx")
377+
378+
355379
def _field_sql(field: str, expression: Any, params: list) -> str:
356380
"""Translate one field predicate to a JSONB containment expression."""
357381
if isinstance(expression, dict):
@@ -625,6 +649,39 @@ def count_rows(self, table: str) -> int:
625649
def drop_table(self, table: str) -> None:
626650
self._execute(f"DROP TABLE IF EXISTS {_quote_identifier(table)}")
627651

652+
# ------------------------------------------------------------------
653+
# Maintenance (RFC 001)
654+
# ------------------------------------------------------------------
655+
def has_vector_index(self, table: str) -> bool:
656+
rows = self._execute(
657+
"SELECT 1 FROM pg_indexes WHERE schemaname = current_schema() "
658+
"AND tablename = %s AND indexdef ILIKE %s",
659+
[table, "%using hnsw%"],
660+
fetch=True,
661+
)
662+
return bool(rows)
663+
664+
def try_advisory_lock(self, classid: int, objid: int) -> bool:
665+
rows = self._execute("SELECT pg_try_advisory_lock(%s, %s)", [classid, objid], fetch=True)
666+
return bool(rows and rows[0] and rows[0][0])
667+
668+
def advisory_unlock(self, classid: int, objid: int) -> None:
669+
self._execute("SELECT pg_advisory_unlock(%s, %s)", [classid, objid], fetch=True)
670+
671+
def create_hnsw_index(self, table: str) -> None:
672+
qi = _quote_identifier(table)
673+
idx = _quote_identifier(_hnsw_index_name(table))
674+
# Non-concurrent build takes ACCESS EXCLUSIVE for the build duration;
675+
# the advisory lock in the caller ensures only one session builds, so
676+
# writes are blocked once rather than by every writer that crossed the
677+
# threshold (the production wedge this serialization fixes).
678+
self._execute(
679+
f"CREATE INDEX IF NOT EXISTS {idx} ON {qi} USING hnsw (embedding vector_cosine_ops)"
680+
)
681+
682+
def analyze_table(self, table: str) -> None:
683+
self._execute(f"ANALYZE {_quote_identifier(table)}")
684+
628685
def close(self) -> None:
629686
with self._lock:
630687
if self._conn is not None:
@@ -1014,6 +1071,60 @@ def health(self) -> HealthStatus:
10141071
return HealthStatus.unhealthy(str(exc))
10151072
return HealthStatus.healthy()
10161073

1074+
def maintenance_state(self) -> dict:
1075+
empty = {"row_count": 0, "vector_index": None, "index_build_complete": False}
1076+
self._ensure_open()
1077+
try:
1078+
if not self._table_exists():
1079+
return empty
1080+
rows = self._client.count_rows(self._table)
1081+
has_index = self._client.has_vector_index(self._table)
1082+
except Exception: # noqa: BLE001 - state report must not raise
1083+
logger.debug("pgvector maintenance state probe failed", exc_info=True)
1084+
return empty
1085+
return {
1086+
"row_count": rows,
1087+
"vector_index": "hnsw" if has_index else None,
1088+
"index_build_complete": has_index,
1089+
}
1090+
1091+
def run_maintenance(self, kind: str):
1092+
from .base import MaintenanceResult, UnsupportedMaintenanceKindError
1093+
1094+
if kind not in PgVectorBackend.maintenance_kinds:
1095+
raise UnsupportedMaintenanceKindError(
1096+
f"pgvector does not support maintenance kind {kind!r}"
1097+
)
1098+
self._ensure_open()
1099+
# Nothing to maintain on a not-yet-materialized table (collection opened
1100+
# create=True but never written) — return noop rather than letting a
1101+
# raw "relation does not exist" error escape.
1102+
if not self._table_exists():
1103+
return MaintenanceResult(kind=kind, status="noop", stats={"reason": "no table"})
1104+
if kind == "analyze":
1105+
self._client.analyze_table(self._table)
1106+
return MaintenanceResult(kind="analyze", status="ran")
1107+
1108+
# reindex → build the optional HNSW index. Opt-in: it makes search
1109+
# approximate, trading the exact-scan 100%-recall default for scale.
1110+
# Serialized with a session advisory lock so concurrent daemon writers
1111+
# learn "already_running" instead of each stacking an ACCESS EXCLUSIVE
1112+
# index build.
1113+
if self._client.has_vector_index(self._table):
1114+
return MaintenanceResult(kind="reindex", status="noop", stats={"vector_index": "hnsw"})
1115+
classid, objid = _MAINTENANCE_LOCK_CLASSID, _advisory_objid(self._table)
1116+
if not self._client.try_advisory_lock(classid, objid):
1117+
return MaintenanceResult(kind="reindex", status="already_running")
1118+
try:
1119+
if self._client.has_vector_index(self._table): # re-check under lock
1120+
return MaintenanceResult(
1121+
kind="reindex", status="noop", stats={"vector_index": "hnsw"}
1122+
)
1123+
self._client.create_hnsw_index(self._table)
1124+
return MaintenanceResult(kind="reindex", status="ran", stats={"vector_index": "hnsw"})
1125+
finally:
1126+
self._client.advisory_unlock(classid, objid)
1127+
10171128

10181129
class PgVectorBackend(BaseBackend):
10191130
name = "pgvector"
@@ -1026,9 +1137,15 @@ class PgVectorBackend(BaseBackend):
10261137
"supports_metadata_filters",
10271138
"supports_lexical_search",
10281139
"supports_namespace_isolation",
1140+
"supports_server_side_indexes",
10291141
"server_mode",
10301142
}
10311143
)
1144+
# "compact" is omitted: Postgres autovacuum reclaims space automatically,
1145+
# so a manual VACUUM kind would be redundant. "reindex" builds the optional
1146+
# HNSW index — an opt-in scale lever, NOT on by default, because it makes
1147+
# vector search approximate (the exact ``<=>`` scan is the 100%-recall path).
1148+
maintenance_kinds = frozenset({"analyze", "reindex"})
10321149

10331150
def __init__(self):
10341151
self._clients: dict[_PgVectorConfig, _PgVectorClient] = {}

mempalace/backends/sqlite_exact.py

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -740,6 +740,63 @@ def health(self) -> HealthStatus:
740740
return HealthStatus.unhealthy("collection closed")
741741
return HealthStatus.healthy()
742742

743+
def maintenance_state(self) -> dict:
744+
try:
745+
rows = self.count()
746+
except Exception:
747+
rows = 0
748+
# vector_index is null by design — exact cosine over every row, no ANN.
749+
state = {"row_count": rows, "vector_index": None}
750+
try:
751+
with self._cursor() as cur:
752+
page_count = cur.execute("PRAGMA page_count").fetchone()
753+
freelist = cur.execute("PRAGMA freelist_count").fetchone()
754+
state["page_count"] = int(page_count[0]) if page_count else 0
755+
state["freelist_pages"] = int(freelist[0]) if freelist else 0
756+
except Exception:
757+
pass
758+
return state
759+
760+
def run_maintenance(self, kind: str):
761+
from .base import MaintenanceResult, UnsupportedMaintenanceKindError
762+
763+
if kind not in SQLiteExactBackend.maintenance_kinds:
764+
raise UnsupportedMaintenanceKindError(
765+
f"sqlite_exact does not support maintenance kind {kind!r}"
766+
)
767+
if kind == "analyze":
768+
# Refresh planner stats. Concurrent runs serialize on the handle lock.
769+
with self._cursor() as cur:
770+
cur.execute("ANALYZE")
771+
return MaintenanceResult(kind="analyze", status="ran")
772+
773+
# compact → VACUUM. It cannot run inside a transaction, so flip the
774+
# connection to autocommit for the duration. The handle lock serializes
775+
# concurrent runs in-process; SQLite's own write lock serializes across
776+
# processes.
777+
before = self.maintenance_state()
778+
with self._handle.lock:
779+
self._ensure_open()
780+
conn = self._handle.conn
781+
prev_isolation = conn.isolation_level
782+
try:
783+
conn.commit()
784+
conn.isolation_level = None
785+
conn.execute("VACUUM")
786+
finally:
787+
conn.isolation_level = prev_isolation
788+
after = self.maintenance_state()
789+
reclaimed = max(0, before.get("page_count", 0) - after.get("page_count", 0))
790+
return MaintenanceResult(
791+
kind="compact",
792+
status="ran",
793+
stats={
794+
"pages_before": before.get("page_count", 0),
795+
"pages_after": after.get("page_count", 0),
796+
"pages_reclaimed": reclaimed,
797+
},
798+
)
799+
743800

744801
class SQLiteExactBackend(BaseBackend):
745802
name = "sqlite_exact"
@@ -754,6 +811,9 @@ class SQLiteExactBackend(BaseBackend):
754811
"local_mode",
755812
}
756813
)
814+
# "reindex" is intentionally omitted: sqlite_exact does exact cosine over
815+
# every row (no ANN index to build), so it has no analogue for it.
816+
maintenance_kinds = frozenset({"analyze", "compact"})
757817

758818
def __init__(self):
759819
self._clients: dict[str, _SQLiteExactHandle] = {}

0 commit comments

Comments
 (0)