feat(backends): shared embedder-identity sidecar + qdrant identity (RFC 001, #1730)

Completes the embedder-identity contract for the last backend (qdrant) and
unifies identity persistence across all local-path backends.

Identity is stored in a small per-palace sidecar (mempalace_embedder.json),
NOT in a backend's mismatch marker. The marker's presence signals "palace
initialized" (reads raise CollectionNotInitializedError when the marker exists
but the store doesn't), so recording identity at first empty open must not
create it — a sidecar is unguarded, so a brand-new palace records identity
immediately. This fixes a latent gap (caught in review) where pgvector/qdrant
palaces stayed permanently "unknown" because the marker isn't written until the
first real write.

- New mempalace/backends/_sidecar.py: shared read/write_embedder_sidecar with
  the isinstance robustness the review bots taught us; chroma, pgvector, and
  qdrant all use it (chroma's inline copy is removed, pgvector switches off the
  marker, qdrant adds it).
- QdrantCollection.get/set_embedder_identity delegate to the sidecar, so
  palace.get_collection enforces a model swap on a qdrant palace exactly like
  the other backends — no live server needed for the check.

Tests: sidecar roundtrip + brand-new-palace recording (creates sidecar without
a marker) + enforcement model-swap raise, for qdrant and pgvector, all
server-free; chroma identity tests unchanged. Full suite: 2495 passed, 82.52%.

Closes #1730. Refs #743, #1724.
This commit is contained in:
Igor Lins e Silva 2026-06-08 09:59:16 -03:00
parent 4ceb880d19
commit ed77125ae3
5 changed files with 219 additions and 97 deletions

View File

@ -0,0 +1,71 @@
"""Shared embedder-identity sidecar (RFC 001).
A small JSON file in the palace directory, keyed by collection name, recording
the embedder identity (``model_name`` / ``dimension``). It is deliberately
*separate* from a backend's mismatch marker: a marker's presence signals
"palace initialized" (reads raise ``CollectionNotInitializedError`` when the
marker exists but the store doesn't), so recording identity at first empty open
must not create one. The sidecar is unguarded, so a brand-new palace can record
identity immediately the same approach the chroma backend uses.
"""
import json
import os
from typing import Optional
EMBEDDER_SIDECAR_FILENAME = "mempalace_embedder.json"
def read_embedder_sidecar(path: Optional[str], collection_name: Optional[str]):
"""Return the recorded :class:`EmbedderIdentity` for ``collection_name``, or None.
Robust to a missing, unreadable, or malformed (non-dict) sidecar any of
those degrade to ``None`` (the ``unknown`` state) rather than raising.
"""
from .base import EmbedderIdentity
if not path or not collection_name or not os.path.isfile(path):
return None
try:
with open(path, encoding="utf-8") as f:
data = json.load(f)
except (OSError, json.JSONDecodeError):
return None
if not isinstance(data, dict):
return None
entry = data.get(collection_name)
if not isinstance(entry, dict) or not entry.get("model_name"):
return None
return EmbedderIdentity(
model_name=str(entry["model_name"]),
dimension=int(entry.get("dimension") or 0),
)
def write_embedder_sidecar(path: Optional[str], collection_name: Optional[str], identity) -> None:
"""Record ``identity`` for ``collection_name`` in the sidecar, creating it if needed.
No-ops for a missing path, missing collection name, or a nameless identity.
Preserves other collections' entries; never raises on I/O failure.
"""
if not path or not collection_name or not identity or not getattr(identity, "model_name", ""):
return
data: dict = {}
if os.path.isfile(path):
try:
with open(path, encoding="utf-8") as f:
loaded = json.load(f)
if isinstance(loaded, dict):
data = loaded
except (OSError, json.JSONDecodeError):
data = {}
data[collection_name] = {
"model_name": str(identity.model_name),
"dimension": int(identity.dimension or 0),
}
try:
with open(path, "w", encoding="utf-8") as f:
json.dump(data, f, indent=2, ensure_ascii=False)
os.chmod(path, 0o600)
except (OSError, NotImplementedError):
pass

View File

@ -17,6 +17,7 @@ from typing import Any, Optional
import chromadb
from chromadb.errors import NotFoundError as _ChromaNotFoundError
from ._sidecar import EMBEDDER_SIDECAR_FILENAME, read_embedder_sidecar, write_embedder_sidecar
from .base import (
BaseBackend,
BaseCollection,
@ -1756,54 +1757,13 @@ class ChromaCollection(BaseCollection):
def _embedder_sidecar_path(self) -> Optional[str]:
if not self._palace_path:
return None
return os.path.join(self._palace_path, "mempalace_embedder.json")
return os.path.join(self._palace_path, EMBEDDER_SIDECAR_FILENAME)
def get_stored_embedder_identity(self):
from .base import EmbedderIdentity
path = self._embedder_sidecar_path()
name = self._collection_name()
if not path or not name or not os.path.isfile(path):
return None
try:
with open(path, encoding="utf-8") as f:
data = json.load(f)
except (OSError, json.JSONDecodeError):
return None
if not isinstance(data, dict):
return None
entry = data.get(name)
if not isinstance(entry, dict) or not entry.get("model_name"):
return None
return EmbedderIdentity(
model_name=str(entry["model_name"]),
dimension=int(entry.get("dimension") or 0),
)
return read_embedder_sidecar(self._embedder_sidecar_path(), self._collection_name())
def set_embedder_identity(self, identity) -> None:
path = self._embedder_sidecar_path()
name = self._collection_name()
if not path or not name or not identity or not identity.model_name:
return
data: dict = {}
if os.path.isfile(path):
try:
with open(path, encoding="utf-8") as f:
loaded = json.load(f)
if isinstance(loaded, dict):
data = loaded
except (OSError, json.JSONDecodeError):
data = {}
data[name] = {
"model_name": str(identity.model_name),
"dimension": int(identity.dimension or 0),
}
try:
with open(path, "w", encoding="utf-8") as f:
json.dump(data, f, indent=2, ensure_ascii=False)
os.chmod(path, 0o600)
except (OSError, NotImplementedError):
pass
write_embedder_sidecar(self._embedder_sidecar_path(), self._collection_name(), identity)
# ---------------------------------------------------------------------------

View File

@ -37,6 +37,7 @@ from urllib import parse as urlparse
import numpy as np
from ._sidecar import EMBEDDER_SIDECAR_FILENAME, read_embedder_sidecar, write_embedder_sidecar
from .base import (
BackendClosedError,
BackendError,
@ -747,6 +748,8 @@ class PgVectorCollection(BaseCollection):
return self._backend._get_embedder_identity(self._palace, self._collection_name)
def set_embedder_identity(self, identity) -> None:
# Sidecar-backed (see PgVectorBackend), so this records even on a
# brand-new palace whose mismatch marker doesn't exist yet.
self._backend._set_embedder_identity(self._palace, self._collection_name, identity)
def _ensure_table(self, dimension: int) -> None:
@ -1255,14 +1258,6 @@ class PgVectorBackend(BaseBackend):
"palace_id": palace.id,
"pgvector": self._marker_target(palace, config),
}
# Preserve recorded embedder identities across marker rewrites — a
# rebuilt marker must not wipe per-collection model-name tracking.
try:
existing = self._read_marker(palace)
except BackendMismatchError:
existing = None
if isinstance(existing, dict) and isinstance(existing.get("embedders"), dict):
marker["embedders"] = existing["embedders"]
marker_path = self._marker_path(palace.local_path)
with open(marker_path, "w", encoding="utf-8") as f:
json.dump(marker, f, indent=2, ensure_ascii=False)
@ -1271,50 +1266,22 @@ class PgVectorBackend(BaseBackend):
except (OSError, NotImplementedError):
pass
# Embedder identity lives in a sidecar, NOT the backend marker: the marker's
# presence signals "palace initialized" (reads raise CollectionNotInitialized
# when the marker exists but the remote table doesn't), so recording identity
# at first empty open must not create it. The sidecar is unguarded — like the
# chroma sidecar — so a brand-new palace can record identity immediately.
@staticmethod
def _embedder_sidecar_path(palace: PalaceRef) -> Optional[str]:
if not palace.local_path:
return None
return os.path.join(palace.local_path, EMBEDDER_SIDECAR_FILENAME)
def _get_embedder_identity(self, palace: PalaceRef, collection_name: str):
from .base import EmbedderIdentity
try:
marker = self._read_marker(palace)
except BackendMismatchError:
return None
if not isinstance(marker, dict):
return None
embedders = marker.get("embedders")
if not isinstance(embedders, dict):
return None
entry = embedders.get(collection_name)
if not isinstance(entry, dict) or not entry.get("model_name"):
return None
return EmbedderIdentity(
model_name=str(entry["model_name"]),
dimension=int(entry.get("dimension") or 0),
)
return read_embedder_sidecar(self._embedder_sidecar_path(palace), collection_name)
def _set_embedder_identity(self, palace: PalaceRef, collection_name: str, identity) -> None:
if not palace.local_path or not identity or not identity.model_name:
return
marker_path = self._marker_path(palace.local_path)
if not os.path.isfile(marker_path):
return
try:
marker = self._read_marker(palace) or {}
except BackendMismatchError:
return
embedders = marker.get("embedders")
if not isinstance(embedders, dict):
embedders = {}
embedders[collection_name] = {
"model_name": str(identity.model_name),
"dimension": int(identity.dimension or 0),
}
marker["embedders"] = embedders
with open(marker_path, "w", encoding="utf-8") as f:
json.dump(marker, f, indent=2, ensure_ascii=False)
try:
os.chmod(marker_path, 0o600)
except (OSError, NotImplementedError):
pass
write_embedder_sidecar(self._embedder_sidecar_path(palace), collection_name, identity)
# ------------------------------------------------------------------
def _client(self, config: _PgVectorConfig) -> _PgVectorClient:

View File

@ -24,6 +24,7 @@ from urllib import request as urlrequest
import numpy as np
from ._sidecar import EMBEDDER_SIDECAR_FILENAME, read_embedder_sidecar, write_embedder_sidecar
from .base import (
BackendClosedError,
BackendMismatchError,
@ -661,6 +662,14 @@ class QdrantCollection(BaseCollection):
def _marker_exists(self) -> bool:
return self._backend._marker_exists(self._palace)
def get_stored_embedder_identity(self):
return self._backend._get_embedder_identity(self._palace, self._collection_name)
def set_embedder_identity(self, identity) -> None:
# Sidecar-backed (see QdrantBackend), so this records even on a
# brand-new palace whose mismatch marker doesn't exist yet.
self._backend._set_embedder_identity(self._palace, self._collection_name, identity)
def _remote_dimension(self) -> Optional[int]:
try:
info = self._client.get_collection_info(self._remote_collection)
@ -1175,6 +1184,23 @@ class QdrantBackend(BaseBackend):
except (OSError, NotImplementedError):
pass
# Embedder identity lives in a sidecar, NOT the backend marker: the marker's
# presence signals "palace initialized" (reads raise CollectionNotInitialized
# when the marker exists but the remote collection doesn't), so recording
# identity at first empty open must not create it. The sidecar is unguarded,
# so a brand-new palace can record identity immediately.
@staticmethod
def _embedder_sidecar_path(palace: PalaceRef) -> Optional[str]:
if not palace.local_path:
return None
return os.path.join(palace.local_path, EMBEDDER_SIDECAR_FILENAME)
def _get_embedder_identity(self, palace: PalaceRef, collection_name: str):
return read_embedder_sidecar(self._embedder_sidecar_path(palace), collection_name)
def _set_embedder_identity(self, palace: PalaceRef, collection_name: str, identity) -> None:
write_embedder_sidecar(self._embedder_sidecar_path(palace), collection_name, identity)
def _client(self, config: _QdrantConfig) -> _QdrantRESTClient:
if self._closed:
raise BackendClosedError("QdrantBackend has been closed")

View File

@ -119,16 +119,17 @@ def test_chroma_identity_roundtrip_via_sidecar(tmp_path):
assert os.path.isfile(os.path.join(str(tmp_path), "mempalace_embedder.json"))
def test_pgvector_marker_preserves_identity_across_rewrite(tmp_path):
# The marker is rebuilt on every collection create; identity must survive.
def test_pgvector_identity_survives_marker_rewrite(tmp_path):
# Identity lives in a sidecar, separate from the mismatch marker, so a
# marker rebuild (which happens on every write) must not affect it.
from mempalace.backends.pgvector import PgVectorBackend, _PgVectorConfig
backend = PgVectorBackend()
cfg = _PgVectorConfig(dsn="postgresql://example", namespace=None)
ref = PalaceRef(id=str(tmp_path), local_path=str(tmp_path))
backend._write_marker(ref, cfg)
# No marker needed to record identity — the sidecar is unguarded.
backend._set_embedder_identity(ref, "mempalace_drawers", EmbedderIdentity("minilm", 384))
backend._write_marker(ref, cfg) # rebuild must not wipe embedders
backend._write_marker(ref, cfg)
got = backend._get_embedder_identity(ref, "mempalace_drawers")
assert got is not None and got.model_name == "minilm" and got.dimension == 384
@ -308,3 +309,100 @@ def test_chroma_corrupt_sidecar_returns_none(tmp_path):
# And a subsequent set still works (overwrites the junk).
col.set_embedder_identity(EmbedderIdentity("minilm", 384))
assert col.get_stored_embedder_identity().model_name == "minilm"
# ---------------------------------------------------------------------------
# qdrant: identity persisted in the local marker (no live qdrant needed)
# ---------------------------------------------------------------------------
def _qdrant_collection(tmp_path, *, write_marker=True):
from mempalace.backends.qdrant import QdrantBackend, QdrantCollection, _QdrantConfig
backend = QdrantBackend()
config = _QdrantConfig(url="http://localhost:6333", api_key=None, namespace=None)
ref = PalaceRef(id=str(tmp_path), local_path=str(tmp_path))
if write_marker:
backend._write_marker(ref, config)
# The identity methods read/write the local marker only; the client is
# never touched, so a placeholder stands in for a live REST connection.
return QdrantCollection(
backend=backend,
client=object(),
config=config,
palace=ref,
collection_name="mempalace_drawers",
remote_collection="mp_drawers_remote",
)
def test_qdrant_identity_survives_marker_rewrite(tmp_path):
from mempalace.backends.qdrant import QdrantBackend, _QdrantConfig
backend = QdrantBackend()
config = _QdrantConfig(url="http://localhost:6333", api_key=None, namespace=None)
ref = PalaceRef(id=str(tmp_path), local_path=str(tmp_path))
backend._write_marker(ref, config)
backend._set_embedder_identity(ref, "mempalace_drawers", EmbedderIdentity("minilm", 384))
backend._write_marker(ref, config) # rebuild must not wipe embedders
got = backend._get_embedder_identity(ref, "mempalace_drawers")
assert got is not None and got.model_name == "minilm" and got.dimension == 384
def test_qdrant_collection_delegates_identity(tmp_path):
col = _qdrant_collection(tmp_path)
assert col.get_stored_embedder_identity() is None
col.set_embedder_identity(EmbedderIdentity("minilm", 384))
got = col.get_stored_embedder_identity()
assert got is not None and got.model_name == "minilm" and got.dimension == 384
def test_qdrant_set_identity_creates_sidecar_when_missing(tmp_path):
# Brand-new palace whose first write hasn't created the marker yet:
# recording identity must create it, not silently no-op into permanent
# "unknown" (the marker-on-write vs record-on-open timing gap).
col = _qdrant_collection(tmp_path, write_marker=False)
assert not col._marker_exists()
col.set_embedder_identity(EmbedderIdentity("minilm", 384))
got = col.get_stored_embedder_identity()
assert got is not None and got.model_name == "minilm"
def _pgvector_collection(tmp_path, *, write_marker=True):
from mempalace.backends.pgvector import PgVectorBackend, PgVectorCollection, _PgVectorConfig
backend = PgVectorBackend()
config = _PgVectorConfig(dsn="postgresql://example", namespace=None)
ref = PalaceRef(id=str(tmp_path), local_path=str(tmp_path))
if write_marker:
backend._write_marker(ref, config)
return PgVectorCollection(
backend=backend,
client=object(),
config=config,
palace=ref,
collection_name="mempalace_drawers",
table="mp_drawers_t",
)
def test_pgvector_set_identity_creates_sidecar_when_missing(tmp_path):
# Same brand-new-palace timing gap as qdrant: recording must create the
# marker rather than no-op.
col = _pgvector_collection(tmp_path, write_marker=False)
assert not col._marker_exists()
col.set_embedder_identity(EmbedderIdentity("minilm", 384))
got = col.get_stored_embedder_identity()
assert got is not None and got.model_name == "minilm"
def test_qdrant_enforcement_model_swap_raises(tmp_path, monkeypatch, clear_identity_cache):
# The enforcement check reads the marker (no server) and compares to the
# configured model — a swap raises just like the local backends.
from mempalace import palace as P
col = _qdrant_collection(tmp_path)
col.set_embedder_identity(EmbedderIdentity("minilm", 384))
monkeypatch.setenv("MEMPALACE_EMBEDDING_MODEL", "embeddinggemma")
with pytest.raises(EmbedderIdentityMismatchError):
P._enforce_embedder_identity(col, str(tmp_path), "mempalace_drawers", create=False)