Skip to content

feat(backends): observable maintenance hooks + pgvector indexed-build path (RFC 001) - #1732

Merged
igorls merged 1 commit into
developfrom
feat/maintenance-hooks
Jun 8, 2026
Merged

feat(backends): observable maintenance hooks + pgvector indexed-build path (RFC 001)#1732
igorls merged 1 commit into
developfrom
feat/maintenance-hooks

Conversation

@igorls

@igorls igorls commented Jun 8, 2026

Copy link
Copy Markdown
Member

Summary

Implements the backend maintenance hooks from RFC 001 (#743) — the last of the deferred follow-ups (#1725) — and gives pgvector an opt-in HNSW index path with concurrency-safe builds. This closes the loop on the "is pgvector doing real vector search?" thread: it stays exact-by-default (100% recall), and an operator at scale can opt into an indexed path safely.

What changed

  • Contract (backends/base.py): MaintenanceResult (statusran / already_running / noop, plus free-form stats), UnsupportedMaintenanceKindError, BaseBackend.maintenance_kinds ClassVar (reserved analyze / compact / reindex; a backend with no analogue MUST omit a kind, not no-op it), and BaseCollection.maintenance_state() / run_maintenance(kind) defaults. EmbeddingCollection forwards both (they're concrete on BaseCollection, so __getattr__ would otherwise shadow them).
  • sqlite_exact: analyze (ANALYZE) + compact (VACUUM with autocommit, reporting pages reclaimed). Omits reindex — exact cosine over every row, no ANN index. maintenance_state reports row/page/freelist counts.
  • pgvector: reindex builds the optional HNSW index (USING hnsw (embedding vector_cosine_ops)), 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 this fixes. It is opt-in: the default exact <=> scan is the 100%-recall path; an HNSW index makes search approximate (trades recall for scale), so an operator invokes it deliberately. Also analyze; omits compact (Postgres autovacuum). Advertises supports_server_side_indexes. maintenance_state reports index presence.
  • qdrant / chroma: empty maintenance_kinds — qdrant self-optimizes, chroma's maintenance is the separate repair CLI. The faithful "omit" default.

Recall posture (intentional)

Building an HNSW index makes pgvector's query approximate, which is why reindex is an explicit operator action rather than automatic. The default path (no index, exact scan) preserves MemPalace's 100%-recall requirement; the hook is the "let operators control scaling characteristics" lever the RFC added. maintenance_state() reports which mode a collection is in.

Tests

tests/test_maintenance_hooks.py (16 tests): contract surface; sqlite_exact against a real backend (analyze/compact/state, reindex-omitted, unknown-kind — all CI-runnable); and the pgvector advisory-lock reindex flow via a fake client covering ran / noop / already_running and lock release, plus advisory-key bounds — no live Postgres needed. Full suite: 2488 passed, 82.47% coverage, ruff check + format --check clean.

Deferred

The §7.3 benchmark three-phase publishing is not wired: the existing benchmarks/ are task-benchmarks (LoCoMo, LongMemEval, …), not backend-comparison harnesses, so there's no harness to record maintenance_state() into yet. Noted for when such a harness exists.

Closes #1725. Refs #743.

Copilot AI review requested due to automatic review settings June 8, 2026 11:35
@igorls
igorls requested a review from milla-jovovich as a code owner June 8, 2026 11:35

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request implements backend maintenance hooks (RFC 001) for the pgvector and sqlite_exact backends, introducing maintenance_state() and run_maintenance(kind) to handle tasks like database analysis, compaction, and index rebuilding. The review feedback highlights two important issues in the pgvector implementation: a critical bug where _hnsw_index_name can generate an index name identical to the table name for 63-character table names, causing a namespace conflict, and a robustness issue where maintenance_state lacks comprehensive exception handling, potentially violating its non-raising contract.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment thread mempalace/backends/pgvector.py Outdated
Comment on lines +368 to +370
def _hnsw_index_name(table: str) -> str:
"""Deterministic index name for ``table``, clamped to Postgres' 63-char limit."""
return f"{table}_hnsw_idx"[:63]

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

critical

Critical Bug: If the table name is exactly 63 characters long (which is the exact length returned by _pg_identifier for any long table name), _hnsw_index_name will return the table name itself because f"{table}_hnsw_idx"[:63] slices the string back to the original table name.

In Postgres, tables and indexes share the same namespace (pg_class). Attempting to create an index with the same name as the table will fail with a relation already exists error. Using _pg_identifier to safely clamp and hash the index name guarantees uniqueness and avoids this conflict.

Suggested change
def _hnsw_index_name(table: str) -> str:
"""Deterministic index name for ``table``, clamped to Postgres' 63-char limit."""
return f"{table}_hnsw_idx"[:63]
def _hnsw_index_name(table: str) -> str:
"""Deterministic index name for ``table``, clamped to Postgres' 63-char limit."""
return _pg_identifier(f"{table}_hnsw_idx")

Comment on lines +1068 to +1082
def maintenance_state(self) -> dict:
self._ensure_open()
if not self._table_exists():
return {"row_count": 0, "vector_index": None, "index_build_complete": False}
rows = self._client.count_rows(self._table)
has_index = False
try:
has_index = self._client.has_vector_index(self._table)
except Exception: # noqa: BLE001 - state report must not raise
logger.debug("pgvector index probe failed", exc_info=True)
return {
"row_count": rows,
"vector_index": "hnsw" if has_index else None,
"index_build_complete": has_index,
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Robustness Issue: The maintenance_state method does not wrap self._table_exists() and self._client.count_rows(self._table) in a try...except block. If there is a database connection issue or operational failure, this method will raise an exception, violating the "must not raise" contract of maintenance_state (as done in sqlite_exact.py). Wrapping all database calls ensures the method safely returns a default state dictionary.

Suggested change
def maintenance_state(self) -> dict:
self._ensure_open()
if not self._table_exists():
return {"row_count": 0, "vector_index": None, "index_build_complete": False}
rows = self._client.count_rows(self._table)
has_index = False
try:
has_index = self._client.has_vector_index(self._table)
except Exception: # noqa: BLE001 - state report must not raise
logger.debug("pgvector index probe failed", exc_info=True)
return {
"row_count": rows,
"vector_index": "hnsw" if has_index else None,
"index_build_complete": has_index,
}
def maintenance_state(self) -> dict:
self._ensure_open()
try:
if not self._table_exists():
return {"row_count": 0, "vector_index": None, "index_build_complete": False}
rows = self._client.count_rows(self._table)
has_index = self._client.has_vector_index(self._table)
except Exception: # noqa: BLE001 - state report must not raise
logger.debug("pgvector maintenance state probe failed", exc_info=True)
return {"row_count": 0, "vector_index": None, "index_build_complete": False}
return {
"row_count": rows,
"vector_index": "hnsw" if has_index else None,
"index_build_complete": has_index,
}

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR implements RFC 001’s observable backend maintenance hooks across the backend contract and in-tree backends, including an operator-invoked pgvector HNSW index build path serialized by advisory locks to prevent concurrent index-build wedges.

Changes:

  • Adds MaintenanceResult, UnsupportedMaintenanceKindError, BaseBackend.maintenance_kinds, and default BaseCollection.maintenance_state() / run_maintenance() behaviors to the backend contract.
  • Implements maintenance hooks for sqlite_exact (ANALYZE + VACUUM/compact with reclaimed-pages stats) and pgvector (ANALYZE + advisory-lock-serialized HNSW “reindex”).
  • Adds delegation in EmbeddingCollection and comprehensive contract/backend tests for maintenance hooks (including a fake pgvector client for advisory-lock flow).

Reviewed changes

Copilot reviewed 6 out of 6 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
mempalace/backends/base.py Introduces the maintenance hook contract types and defaults on the base interfaces.
mempalace/backends/sqlite_exact.py Implements sqlite maintenance state reporting plus analyze/compact maintenance kinds.
mempalace/backends/pgvector.py Adds advisory-lock helpers and implements analyze + lock-serialized HNSW index build (reindex).
mempalace/backends/embedding_wrapper.py Ensures maintenance hook calls delegate to the wrapped collection (avoids BaseCollection method shadowing).
mempalace/backends/__init__.py Re-exports MaintenanceResult and UnsupportedMaintenanceKindError from the public backends package surface.
tests/test_maintenance_hooks.py Adds test coverage for the new contract surface and sqlite/pgvector maintenance behaviors.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread mempalace/backends/pgvector.py Outdated
Comment on lines +368 to +370
def _hnsw_index_name(table: str) -> str:
"""Deterministic index name for ``table``, clamped to Postgres' 63-char limit."""
return f"{table}_hnsw_idx"[:63]
Comment on lines +1091 to +1095
self._ensure_open()
if kind == "analyze":
self._client.analyze_table(self._table)
return MaintenanceResult(kind="analyze", status="ran")

Comment thread mempalace/backends/pgvector.py Outdated
# across daemon writers (RFC 001). classid is a fixed mempalace constant;
# objid is a stable per-table key. Both must fit a signed int4, which
# ``pg_advisory_lock(int4, int4)`` requires.
_MAINTENANCE_LOCK_CLASSID = 0x4D454D50 # "MEMP" → 1296256848, a valid int4
… 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.
@igorls
igorls force-pushed the feat/maintenance-hooks branch from a65c065 to fdbafe5 Compare June 8, 2026 11:44
@igorls
igorls merged commit 4ceb880 into develop Jun 8, 2026
8 checks passed
igorls added a commit that referenced this pull request Aug 11, 2026
Close the last open items on #743 before merge:

- Conformance: document two isolation arms (cross-id for all backends;
  same-id/different-namespace for supports_namespace_isolation advertisers).
- No silent drop: non-advertising backends must raise UnsupportedCapabilityError
  when PalaceRef.namespace is set, rather than accept-and-ignore.
- Wire require_namespace_support() into chroma/sqlite_exact; add conformance test.
- Refresh implementation-status banner now that #1727/#1731/#1732/#1734 landed.
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.

RFC 001 §7.3: implement backend maintenance hooks (observable run_maintenance)

2 participants