fix(palace): stratify state messages for empty/missing palace (#1498)

`mempalace status`, `search`, and `compress` printed the same misleading
"No palace found / Run: mempalace init" output for three distinct
palace states (no dir / no DB / no collection). Most user-visible on
the first-run state where `init` had run but `mine` had not: the hint
to re-run `init` is a no-op and wastes the user's time.

Backend gets a new typed exception `CollectionNotInitializedError`
(subclass of `PalaceNotFoundError`, transitively `FileNotFoundError`,
so legacy callers keep working). `ChromaBackend.get_collection(
create=False)` wraps chromadb's bare `NotFoundError` as the new typed
exception instead of leaking the chromadb-specific class to callers.

A new internal helper `_open_collection_or_explain` in `palace.py`
runs filesystem-first state checks before the backend call to avoid
chromadb's lazy `chroma.sqlite3` creation as a side-effect of a
read-only inspection, then catches the typed exceptions and prints a
state-specific actionable message. `BackendClosedError` is explicitly
re-raised so a programmer error is not masked as a UX hint.

Two CLI bug sites route through the helper: `miner.status` and
`cli.cmd_compress`. `searcher.search` catches the typed exceptions
directly so it can preserve the cause chain in `SearchError(...) from
e` for programmatic search-API consumers. `cli.cmd_sync` gained an
inline filesystem distinction (no helper needed: it does not use the
collection handle). `repair.status` (capacity check, which by design
must work on corrupted palaces without opening a chromadb client) got
the same distinction via `sqlite_drawer_count`-based empty detection.
The MCP `tool_status` is intentionally left alone: PR #831 already
fixed it there with a `create=True` bootstrap strategy appropriate
for programmatic clients.
This commit is contained in:
mvalentsev 2026-05-17 03:56:50 +05:00
parent bb313961f3
commit c933095a2f
13 changed files with 512 additions and 24 deletions

View File

@ -19,6 +19,7 @@ from .base import (
BackendError,
BaseBackend,
BaseCollection,
CollectionNotInitializedError,
DimensionMismatchError,
EmbedderIdentityMismatchError,
GetResult,
@ -46,6 +47,7 @@ __all__ = [
"BaseCollection",
"ChromaBackend",
"ChromaCollection",
"CollectionNotInitializedError",
"DimensionMismatchError",
"EmbedderIdentityMismatchError",
"GetResult",

View File

@ -35,6 +35,18 @@ class PalaceNotFoundError(BackendError, FileNotFoundError):
"""
class CollectionNotInitializedError(PalaceNotFoundError):
"""Raised when the palace exists on disk but the requested collection has
never been created (e.g. ``init`` ran but ``mine`` has not).
Distinct from :class:`PalaceNotFoundError`: the palace dir and DB are
present and valid, only the collection has not been bootstrapped yet.
Subclass of :class:`PalaceNotFoundError` (and therefore
:class:`FileNotFoundError`) so legacy callers catching either parent
keep working unchanged.
"""
class BackendClosedError(BackendError):
"""Raised when a backend method is called after ``close()``."""

View File

@ -16,6 +16,7 @@ from chromadb.errors import NotFoundError as _ChromaNotFoundError
from .base import (
BaseBackend,
BaseCollection,
CollectionNotInitializedError,
GetResult,
HealthStatus,
PalaceNotFoundError,
@ -1380,7 +1381,10 @@ class ChromaBackend(BaseBackend):
**ef_kwargs,
)
else:
collection = client.get_collection(collection_name, **ef_kwargs)
try:
collection = client.get_collection(collection_name, **ef_kwargs)
except _ChromaNotFoundError as e:
raise CollectionNotInitializedError(palace_path) from e
_pin_hnsw_threads(collection)
return ChromaCollection(collection, palace_path=palace_path)

View File

@ -590,6 +590,10 @@ def cmd_sync(args):
if not os.path.isdir(palace_path):
print(f"\n No palace found at {palace_path}")
return
if not os.path.isfile(os.path.join(palace_path, "chroma.sqlite3")):
print(f"\n Palace dir at {palace_path} exists but has no chroma.sqlite3 yet.")
print(" Run: mempalace mine <dir>")
return
project_dirs = []
if args.dir:
@ -1007,15 +1011,20 @@ def cmd_compress(args):
else:
dialect = Dialect()
# Connect to palace
backend = ChromaBackend()
try:
col = backend.get_collection(palace_path, "mempalace_drawers")
except Exception:
print(f"\n No palace found at {palace_path}")
print(" Run: mempalace init <dir> then mempalace mine <dir>")
# State-aware open: distinguish "no palace" from "initialized but empty"
# from "corrupt" via the shared helper (#1498). MCP and library callers
# catch the backend exceptions directly; CLI gets the friendly print.
from .palace import _open_collection_or_explain
col = _open_collection_or_explain(palace_path, collection_name="mempalace_drawers")
if col is None:
sys.exit(1)
# Backend instance for the closets write below. chromadb's
# PersistentClient is cached per palace path internally, so this second
# ChromaBackend instance shares the underlying client with the helper.
backend = ChromaBackend()
# Query drawers in batches to avoid SQLite variable limit (~999)
where = {"wing": args.wing} if args.wing else None
_BATCH = 500

View File

@ -22,6 +22,7 @@ from typing import Optional
from .palace import (
NORMALIZE_VERSION,
SKIP_DIRS,
_open_collection_or_explain,
build_closet_lines,
file_already_mined,
get_closets_collection,
@ -1355,11 +1356,8 @@ def _compute_topic_tunnels_for_wing(wing: str) -> int:
def status(palace_path: str):
"""Show what's been filed in the palace."""
try:
col = get_collection(palace_path, create=False)
except Exception:
print(f"\n No palace found at {palace_path}")
print(" Run: mempalace init <dir> then mempalace mine <dir>")
col = _open_collection_or_explain(palace_path)
if col is None:
return
# Count by wing and room — paginate to avoid SQLite "too many SQL

View File

@ -13,6 +13,7 @@ import sys
import threading
from typing import Optional
from .backends import BackendClosedError, CollectionNotInitializedError, PalaceNotFoundError
from .backends.chroma import ChromaBackend
logger = logging.getLogger("mempalace_mcp")
@ -78,6 +79,72 @@ def get_closets_collection(palace_path: str, create: bool = True):
return get_collection(palace_path, collection_name="mempalace_closets", create=create)
def _open_collection_or_explain(
palace_path: str,
*,
collection_name: Optional[str] = None,
out=None,
):
"""Open the palace collection or print a state-specific message and return ``None``.
For CLI and repair commands that want consistent, actionable user-facing
messages distinguishing four "not-healthy" states from one another. MCP
and library callers should catch
:class:`mempalace.backends.PalaceNotFoundError` /
:class:`mempalace.backends.CollectionNotInitializedError` directly.
The MCP server (``mcp_server.tool_status``) deliberately does NOT use
this helper: it uses ``_get_collection(create=db_exists)`` so a valid
palace whose collection was never bootstrapped lazily gets one on the
first status call, and a corruption-detection sqlite-only probe fires
first when the vector path is disabled (see PR #831 / issue #830).
State A: palace dir is absent.
State B: dir is present but ``chroma.sqlite3`` is absent. The helper
short-circuits to a message before reaching the backend, because
``chromadb.PersistentClient`` lazily creates the DB file on first
open calling the backend on this state would silently mutate
the filesystem for what should be a read-only inspection.
State C: DB is present but the ``mempalace_drawers`` collection has
never been bootstrapped (``init`` ran, ``mine`` has not).
State D: healthy returns the opened collection.
State E: an unexpected error opens the backend message points the
user at ``repair-status`` for further diagnosis.
``out`` is the message sink; defaults to the builtin ``print``. Pass a
callable (e.g. a repair progress emitter) to route messages through it.
"""
emit = out if out is not None else print
if not os.path.isdir(palace_path):
emit(f"\n No palace found at {palace_path}")
emit(" Run: mempalace init <dir> then mempalace mine <dir>")
return None
if not os.path.isfile(os.path.join(palace_path, "chroma.sqlite3")):
emit(f"\n Palace dir at {palace_path} exists but has no chroma.sqlite3 yet.")
emit(" Run: mempalace mine <dir>")
return None
try:
return get_collection(palace_path, collection_name=collection_name, create=False)
except CollectionNotInitializedError:
emit(f"\n Palace at {palace_path} is initialized but empty (no drawers yet).")
emit(" Run: mempalace mine <dir>")
return None
except PalaceNotFoundError:
emit(f"\n No palace found at {palace_path}")
emit(" Run: mempalace init <dir> then mempalace mine <dir>")
return None
except BackendClosedError:
# Surface this as a programmer error, not a palace-state UX message:
# a closed backend means the caller violated the backend lifecycle,
# not that the palace on disk is in a recoverable state.
raise
except Exception as e: # noqa: BLE001 — backend exceptions vary (chromadb, OSError, lock errors)
emit(f"\n Error opening palace at {palace_path}: {e!r}")
emit(" Try: mempalace repair-status --palace <path>")
return None
CLOSET_CHAR_LIMIT = 1500 # fill closet until ~1500 chars, then start a new one
CLOSET_EXTRACT_WINDOW = 5000 # how many chars of source content to scan for entities/topics

View File

@ -1222,6 +1222,21 @@ def status(palace_path=None, collection_name: Optional[str] = None) -> dict:
print(" No palace found.\n")
return {"status": "unknown", "message": "no palace at path"}
db_path = os.path.join(palace_path, "chroma.sqlite3")
if not os.path.isfile(db_path):
print(f" Palace dir at {palace_path} exists but has no chroma.sqlite3 yet.\n")
return {"status": "uninitialized", "message": "palace has no chroma.sqlite3 yet"}
# Cheap collection-existence check via sqlite. By design this function
# never opens a chromadb client (see the docstring); sqlite_drawer_count
# reads chroma.sqlite3 directly and returns None on any schema/lock error
# (chromadb version drift, missing tables, locked file). None means fall
# through and let hnsw_capacity_status report "unknown".
drawer_row_count = sqlite_drawer_count(palace_path, collection_name)
if isinstance(drawer_row_count, int) and drawer_row_count == 0:
print(" Palace is initialized but empty (no drawers yet).\n")
return {"status": "empty", "message": "palace has no drawers yet"}
drawers = hnsw_capacity_status(palace_path, collection_name)
closets = hnsw_capacity_status(palace_path, CLOSETS_COLLECTION_NAME)

View File

@ -16,6 +16,7 @@ import re
import sqlite3
from pathlib import Path
from .backends import CollectionNotInitializedError, PalaceNotFoundError
from .palace import get_closets_collection, get_collection
# Closet pointer line format: "topic|entities|→drawer_id_a,drawer_id_b"
@ -297,7 +298,13 @@ def search(query: str, palace_path: str, wing: str = None, room: str = None, n_r
"""
try:
col = get_collection(palace_path, create=False)
except Exception as e:
except CollectionNotInitializedError as e:
# State C from #1498: palace initialized but never mined.
print(f"\n Palace at {palace_path} is initialized but empty (no drawers yet).")
print(" Run: mempalace mine <dir>")
raise SearchError(f"Palace at {palace_path} is initialized but empty") from e
except PalaceNotFoundError as e:
# State A from #1498: palace dir missing.
print(f"\n No palace found at {palace_path}")
print(" Run: mempalace init <dir> then mempalace mine <dir>")
raise SearchError(f"No palace found at {palace_path}") from e

View File

@ -9,7 +9,9 @@ import chromadb
import pytest
from mempalace.backends import (
CollectionNotInitializedError,
GetResult,
PalaceNotFoundError,
PalaceRef,
QueryResult,
UnsupportedFilterError,
@ -993,6 +995,43 @@ def test_get_collection_applies_retrofit_on_existing_palace(tmp_path):
assert wrapper._collection.configuration_json["hnsw"]["num_threads"] == 1
def test_get_collection_raises_palace_not_found_when_dir_missing(tmp_path):
"""create=False on a missing dir raises PalaceNotFoundError, not the
new CollectionNotInitializedError. The two states must be distinguishable
so callers can render state-specific messages (#1498)."""
missing = tmp_path / "no-such-dir"
with pytest.raises(PalaceNotFoundError) as excinfo:
ChromaBackend().get_collection(
str(missing),
collection_name="mempalace_drawers",
create=False,
)
# Must be the parent class, not the new subclass: dir is genuinely absent.
assert not isinstance(excinfo.value, CollectionNotInitializedError)
def test_get_collection_raises_collection_not_initialized_on_empty_palace(tmp_path):
"""When the palace dir + DB exist but the collection has never been
created, ChromaBackend.get_collection(create=False) raises the new
CollectionNotInitializedError instead of leaking chromadb.NotFoundError
(#1498)."""
palace_path = tmp_path / "palace"
palace_path.mkdir()
# PersistentClient lazily creates chroma.sqlite3 — no collection yet.
chromadb.PersistentClient(path=str(palace_path))
assert (palace_path / "chroma.sqlite3").is_file()
with pytest.raises(CollectionNotInitializedError) as excinfo:
ChromaBackend().get_collection(
str(palace_path),
collection_name="mempalace_drawers",
create=False,
)
# Backward-compat: subclass of PalaceNotFoundError (and FileNotFoundError).
assert isinstance(excinfo.value, PalaceNotFoundError)
assert isinstance(excinfo.value, FileNotFoundError)
def test_quarantine_invalid_hnsw_metadata_renames_missing_dimensionality(tmp_path):
palace = tmp_path / "palace"
palace.mkdir()

View File

@ -1041,16 +1041,45 @@ def test_cmd_repair_aborts_without_confirmation(mock_config_cls, tmp_path, capsy
@patch("mempalace.cli.MempalaceConfig")
def test_cmd_compress_no_palace(mock_config_cls, capsys):
mock_config_cls.return_value.palace_path = "/fake/palace"
def test_cmd_sync_no_palace_dir(mock_config_cls, tmp_path, capsys):
"""cmd_sync on a missing palace dir prints the State A message (#1498)."""
from mempalace.cli import cmd_sync
palace_path = tmp_path / "nonexistent"
mock_config_cls.return_value.palace_path = str(palace_path)
args = argparse.Namespace(palace=None, dir=None, root=[], wing=None, dry_run=False)
cmd_sync(args)
captured = capsys.readouterr()
assert "No palace found" in captured.out + captured.err
@patch("mempalace.cli.MempalaceConfig")
def test_cmd_sync_palace_dir_no_db(mock_config_cls, tmp_path, capsys):
"""cmd_sync on a palace dir without chroma.sqlite3 prints the State B
message and does NOT trigger chromadb's lazy DB creation (#1498)."""
from mempalace.cli import cmd_sync
mock_config_cls.return_value.palace_path = str(tmp_path)
args = argparse.Namespace(palace=None, dir=None, root=[], wing=None, dry_run=False)
cmd_sync(args)
captured = capsys.readouterr()
assert "has no chroma.sqlite3 yet" in captured.out + captured.err
# Side-effect-free: backend not invoked.
assert list(tmp_path.iterdir()) == []
@patch("mempalace.cli.MempalaceConfig")
def test_cmd_compress_no_palace(mock_config_cls, tmp_path, capsys):
"""cmd_compress exits non-zero with a 'No palace found' message on a missing dir.
Uses a real non-existent tmp_path so the stratified state helper (#1498)
walks the State A branch instead of hitting the chromadb backend.
"""
mock_config_cls.return_value.palace_path = str(tmp_path / "nonexistent")
args = argparse.Namespace(palace=None, wing=None, dry_run=False, config=None)
mock_backend = MagicMock()
mock_backend.get_collection.side_effect = Exception("no palace")
with (
patch("mempalace.backends.chroma.ChromaBackend", return_value=mock_backend),
pytest.raises(SystemExit),
):
with pytest.raises(SystemExit):
cmd_compress(args)
assert "No palace found" in capsys.readouterr().out
@patch("mempalace.cli.MempalaceConfig")
@ -1060,7 +1089,10 @@ def test_cmd_compress_no_drawers(mock_config_cls, capsys):
mock_col = MagicMock()
mock_col.get.return_value = {"documents": [], "metadatas": [], "ids": []}
mock_backend = _mock_backend_for(col=mock_col)
with patch("mempalace.backends.chroma.ChromaBackend", return_value=mock_backend):
with (
patch("mempalace.palace._open_collection_or_explain", return_value=mock_col),
patch("mempalace.backends.chroma.ChromaBackend", return_value=mock_backend),
):
cmd_compress(args)
out = capsys.readouterr().out
assert "No drawers found" in out
@ -1103,6 +1135,7 @@ def test_cmd_compress_dry_run(mock_config_cls, capsys):
mock_dialect_mod = _make_mock_dialect_module(mock_dialect)
with (
patch("mempalace.palace._open_collection_or_explain", return_value=mock_col),
patch("mempalace.backends.chroma.ChromaBackend", return_value=mock_backend),
patch.dict("sys.modules", {"mempalace.dialect": mock_dialect_mod}),
):
@ -1127,6 +1160,7 @@ def test_cmd_compress_with_config(mock_config_cls, tmp_path, capsys):
mock_dialect_mod = _make_mock_dialect_module(mock_dialect)
with (
patch("mempalace.palace._open_collection_or_explain", return_value=mock_col),
patch("mempalace.backends.chroma.ChromaBackend", return_value=mock_backend),
patch.dict("sys.modules", {"mempalace.dialect": mock_dialect_mod}),
):
@ -1167,6 +1201,7 @@ def test_cmd_compress_stores_results(mock_config_cls, capsys):
mock_dialect_mod = _make_mock_dialect_module(mock_dialect)
with (
patch("mempalace.palace._open_collection_or_explain", return_value=mock_col),
patch("mempalace.backends.chroma.ChromaBackend", return_value=mock_backend),
patch.dict("sys.modules", {"mempalace.dialect": mock_dialect_mod}),
):

View File

@ -442,6 +442,41 @@ def test_status_missing_palace_does_not_create_empty_collection(tmp_path, capsys
assert not palace_path.exists()
def test_status_initialized_but_empty_palace_reports_empty(tmp_path, capsys):
"""State C from #1498: palace dir + chroma.sqlite3 exist but no drawers
have been mined yet. status() must print the 'initialized but empty'
message and suggest `mempalace mine`, not the misleading 'No palace
found' / 'Run init' message."""
import chromadb
palace_path = tmp_path / "empty-palace"
palace_path.mkdir()
chromadb.PersistentClient(path=str(palace_path)) # creates chroma.sqlite3
assert (palace_path / "chroma.sqlite3").is_file()
status(str(palace_path))
out = capsys.readouterr().out
assert "initialized but empty" in out
assert "mempalace mine" in out
assert "No palace found" not in out
def test_status_palace_dir_without_db_reports_uninitialized(tmp_path, capsys):
"""State B from #1498: palace dir exists but chroma.sqlite3 is absent.
Helper must short-circuit before invoking chromadb (which would lazily
create the DB file as a side effect of a read-only inspection)."""
palace_path = tmp_path / "no-db-palace"
palace_path.mkdir()
status(str(palace_path))
out = capsys.readouterr().out
assert "has no chroma.sqlite3 yet" in out
# Side-effect check: chromadb was never touched.
assert list(palace_path.iterdir()) == []
def test_status_handles_none_metadata_without_crash(tmp_path, capsys):
"""status must not crash when col.get returns a None entry in metadatas.
@ -462,7 +497,7 @@ def test_status_handles_none_metadata_without_crash(tmp_path, capsys):
"metadatas": [{"wing": "proj", "room": "r"}, None],
}
with patch("mempalace.miner.get_collection", return_value=FakeCol()):
with patch("mempalace.miner._open_collection_or_explain", return_value=FakeCol()):
status(str(tmp_path))
out = capsys.readouterr().out

170
tests/test_palace.py Normal file
View File

@ -0,0 +1,170 @@
"""Tests for mempalace.palace shared helpers."""
import chromadb
from mempalace.backends import CollectionNotInitializedError, PalaceNotFoundError
from mempalace.palace import _open_collection_or_explain, get_collection
def _capture():
"""Return (emit, lines) — emit appends to lines for inspection."""
lines: list[str] = []
return lines.append, lines
def test_open_collection_or_explain_state_a_missing_dir(tmp_path):
"""State A: palace dir does not exist."""
emit, lines = _capture()
missing = tmp_path / "no-such-palace"
result = _open_collection_or_explain(str(missing), out=emit)
assert result is None
assert any("No palace found" in line for line in lines)
assert any("mempalace init" in line for line in lines)
# Helper must not create the directory.
assert not missing.exists()
def test_open_collection_or_explain_state_b_no_db(tmp_path):
"""State B: dir exists but chroma.sqlite3 does not.
Critical invariant: the helper must NOT trigger chromadb's lazy DB
creation by reaching the backend. The dir must remain empty after
the call so a read-only inspection stays read-only.
"""
emit, lines = _capture()
palace = tmp_path / "palace"
palace.mkdir()
assert not (palace / "chroma.sqlite3").exists()
result = _open_collection_or_explain(str(palace), out=emit)
assert result is None
assert any("has no chroma.sqlite3 yet" in line for line in lines)
# No side-effect: backend was not invoked.
assert list(palace.iterdir()) == []
def test_open_collection_or_explain_state_c_no_collection(tmp_path):
"""State C: DB file exists but the collection has never been created."""
emit, lines = _capture()
palace = tmp_path / "palace"
palace.mkdir()
chromadb.PersistentClient(path=str(palace)) # creates DB, no collection
assert (palace / "chroma.sqlite3").is_file()
result = _open_collection_or_explain(str(palace), out=emit)
assert result is None
assert any("initialized but empty" in line for line in lines)
assert any("mempalace mine" in line for line in lines)
def test_open_collection_or_explain_state_d_healthy(tmp_path):
"""State D: healthy palace — returns the opened collection silently."""
emit, lines = _capture()
palace = tmp_path / "palace"
palace.mkdir()
get_collection(str(palace), create=True) # bootstrap collection
result = _open_collection_or_explain(str(palace), out=emit)
assert result is not None
assert lines == [] # healthy path is silent
def test_open_collection_or_explain_state_e_unexpected_error(tmp_path, monkeypatch):
"""State E: unexpected error opening the backend routes to repair hint."""
emit, lines = _capture()
palace = tmp_path / "palace"
palace.mkdir()
(palace / "chroma.sqlite3").touch() # pass the isfile guard
def boom(*args, **kwargs):
raise RuntimeError("disk on fire")
monkeypatch.setattr("mempalace.palace.get_collection", boom)
result = _open_collection_or_explain(str(palace), out=emit)
assert result is None
assert any("Error opening palace" in line for line in lines)
assert any("repair-status" in line for line in lines)
def test_open_collection_or_explain_default_sink_is_print(tmp_path, capsys):
"""When out is None, messages go through builtin print → stdout."""
missing = tmp_path / "no-such-palace"
result = _open_collection_or_explain(str(missing))
assert result is None
assert "No palace found" in capsys.readouterr().out
def test_open_collection_or_explain_propagates_palace_not_found_from_backend(tmp_path, monkeypatch):
"""If the backend raises bare PalaceNotFoundError after our filesystem
guards (rare race or backend-internal "not found"), the helper still
prints the State A message and returns None."""
emit, lines = _capture()
palace = tmp_path / "palace"
palace.mkdir()
(palace / "chroma.sqlite3").touch()
def raise_pnf(*args, **kwargs):
raise PalaceNotFoundError(str(palace))
monkeypatch.setattr("mempalace.palace.get_collection", raise_pnf)
result = _open_collection_or_explain(str(palace), out=emit)
assert result is None
assert any("No palace found" in line for line in lines)
def test_open_collection_or_explain_reraises_backend_closed_error(tmp_path, monkeypatch):
"""BackendClosedError is a programmer error (caller violated the backend
lifecycle), not a palace-state UX condition. The helper must propagate
it instead of swallowing it into the State E "repair-status" hint.
Without this re-raise, a closed default backend would silently mask
every call site as "Error opening palace ... Try: repair-status"
even when the actual fix is to stop using a closed backend handle.
"""
from mempalace.backends import BackendClosedError
palace = tmp_path / "palace"
palace.mkdir()
(palace / "chroma.sqlite3").touch()
def raise_closed(*args, **kwargs):
raise BackendClosedError("ChromaBackend has been closed")
monkeypatch.setattr("mempalace.palace.get_collection", raise_closed)
import pytest
with pytest.raises(BackendClosedError):
_open_collection_or_explain(str(palace))
def test_open_collection_or_explain_distinguishes_collection_subclass(tmp_path, monkeypatch):
"""The helper must surface CollectionNotInitializedError as the
'empty' message rather than the broader 'No palace found' message,
even though the former subclasses the latter."""
emit, lines = _capture()
palace = tmp_path / "palace"
palace.mkdir()
(palace / "chroma.sqlite3").touch()
def raise_cnie(*args, **kwargs):
raise CollectionNotInitializedError(str(palace))
monkeypatch.setattr("mempalace.palace.get_collection", raise_cnie)
result = _open_collection_or_explain(str(palace), out=emit)
assert result is None
assert any("initialized but empty" in line for line in lines)
assert not any("No palace found" in line for line in lines)

View File

@ -538,9 +538,104 @@ def test_rebuild_index_default_uses_configured_collection(mock_backend_cls, mock
]
def test_status_returns_uninitialized_when_db_missing(tmp_path, capsys):
"""repair.status on a palace dir without chroma.sqlite3 returns a
structured status (no chromadb client opened, per the design that
repair-status must work even on corrupted palaces #1498)."""
# tmp_path exists, no chroma.sqlite3
result = repair.status(palace_path=str(tmp_path))
assert result["status"] == "uninitialized"
assert "no chroma.sqlite3" in result["message"]
captured = capsys.readouterr()
assert "has no chroma.sqlite3 yet" in captured.out + captured.err
def test_status_returns_empty_when_db_present_no_drawers(tmp_path, capsys):
"""repair.status on a palace with chroma.sqlite3 but zero drawer rows
returns a structured 'empty' status, distinguishable from 'unknown' /
'uninitialized' (#1498). Mocks sqlite_drawer_count to assert the
return-shape contract; see the real-disk sibling below for the
no-chromadb-client invariant."""
(tmp_path / "chroma.sqlite3").touch()
with patch("mempalace.repair.sqlite_drawer_count", return_value=0):
result = repair.status(palace_path=str(tmp_path))
assert result["status"] == "empty"
assert "no drawers yet" in result["message"]
captured = capsys.readouterr()
assert "initialized but empty" in captured.out + captured.err
def test_status_empty_palace_never_opens_chromadb_client(tmp_path):
"""Design invariant from #1498: repair.status on an initialized-but-empty
palace must NOT open a chromadb client. Opening would materialize HNSW
segment state files on disk, breaking the promise that repair-status is
safe to run on corrupted palaces.
Real-disk sibling of test_status_returns_empty_when_db_present_no_drawers:
bootstrap a real chroma.sqlite3 via PersistentClient (creates the DB
file but no collection), then assert repair.status returns 'empty' and
no chromadb segment artifacts appeared in the dir."""
import chromadb
chromadb.PersistentClient(path=str(tmp_path))
before = sorted(p.name for p in tmp_path.iterdir())
result = repair.status(palace_path=str(tmp_path))
after = sorted(p.name for p in tmp_path.iterdir())
assert result["status"] == "empty", result
# repair.status must not create new files; chromadb writes HNSW segment
# state and *.bin payloads on collection open — none of those should
# appear here.
assert before == after, f"repair.status mutated palace on disk: before={before} after={after}"
def test_status_falls_through_to_capacity_when_sqlite_count_unreadable(tmp_path):
"""When sqlite_drawer_count returns None (schema drift / locked file),
repair.status must fall through to hnsw_capacity_status instead of
short-circuiting on 'empty' (#1498)."""
(tmp_path / "chroma.sqlite3").touch()
with (
patch("mempalace.repair.sqlite_drawer_count", return_value=None),
patch("mempalace.repair.hnsw_capacity_status") as capacity_status,
):
capacity_status.side_effect = [
{
"sqlite_count": None,
"hnsw_count": None,
"divergence": None,
"diverged": False,
"status": "unknown",
"message": "",
},
{
"sqlite_count": None,
"hnsw_count": None,
"divergence": None,
"diverged": False,
"status": "unknown",
"message": "",
},
]
result = repair.status(palace_path=str(tmp_path))
# Did not short-circuit on 'empty': fell through to capacity check.
# The healthy/fall-through path returns {drawers, closets} dicts, no top-level "status" key.
assert "status" not in result or result["status"] != "empty"
assert "drawers" in result and "closets" in result
assert capacity_status.called
def test_status_default_uses_configured_drawer_collection(tmp_path):
# Provide the on-disk preconditions the stratified state helper (#1498)
# checks before reaching the capacity probe: chroma.sqlite3 file exists
# and sqlite_drawer_count returns a positive number (palace not empty).
(tmp_path / "chroma.sqlite3").touch()
with (
patch("mempalace.repair._drawers_collection_name", return_value="custom_drawers"),
patch("mempalace.repair.sqlite_drawer_count", return_value=1),
patch("mempalace.repair.hnsw_capacity_status") as capacity_status,
):
capacity_status.side_effect = [