Skip to content

Commit e8ce2c2

Browse files
panic handling
1 parent 5edfe0c commit e8ce2c2

2 files changed

Lines changed: 181 additions & 22 deletions

File tree

src/holoviz_mcp/holoviz_mcp/data.py

Lines changed: 77 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44
import logging
55
import os
66
import re
7+
import shutil
8+
import time
79
from pathlib import Path
810
from typing import Any
911
from typing import Literal
@@ -12,6 +14,7 @@
1214
import chromadb
1315
import git
1416
from chromadb.api.collection_configuration import CreateCollectionConfiguration
17+
from chromadb.api.shared_system_client import SharedSystemClient
1518
from fastmcp import Context
1619
from nbconvert import MarkdownExporter
1720
from nbformat import read as nbread
@@ -490,10 +493,20 @@ def __init__(self, *, data_dir: Optional[Path] = None, repos_dir: Optional[Path]
490493
if not config.server.anonymized_telemetry:
491494
os.environ["ANONYMIZED_TELEMETRY"] = "False"
492495

493-
# Initialize ChromaDB
494-
self.chroma_client = chromadb.PersistentClient(path=str(self._vector_db_path))
495-
496-
self.collection = self.chroma_client.get_or_create_collection("holoviz_docs", configuration=_CROMA_CONFIGURATION)
496+
# Initialize ChromaDB with health check
497+
try:
498+
self.chroma_client = chromadb.PersistentClient(path=str(self._vector_db_path))
499+
self.collection = self.chroma_client.get_or_create_collection("holoviz_docs", configuration=_CROMA_CONFIGURATION)
500+
self.collection.count() # Probe read to verify health
501+
except (KeyboardInterrupt, SystemExit):
502+
raise
503+
except BaseException as e:
504+
logger.error("ChromaDB initialization failed (%s: %s). Wiping and reinitializing.", type(e).__name__, e)
505+
shutil.rmtree(self._vector_db_path, ignore_errors=True)
506+
self._vector_db_path.mkdir(parents=True, exist_ok=True)
507+
SharedSystemClient.clear_system_cache()
508+
self.chroma_client = chromadb.PersistentClient(path=str(self._vector_db_path))
509+
self.collection = self.chroma_client.get_or_create_collection("holoviz_docs", configuration=_CROMA_CONFIGURATION)
497510

498511
# Lazy-initialized async lock for database operations to prevent corruption from concurrent access
499512
self._db_lock: Optional[asyncio.Lock] = None
@@ -527,12 +540,37 @@ def db_lock(self) -> asyncio.Lock:
527540
self._db_lock = asyncio.Lock()
528541
return self._db_lock
529542

543+
@property
544+
def _backup_path(self) -> Path:
545+
"""Path to the backup copy of the vector database directory."""
546+
return Path(str(self._vector_db_path) + ".bak")
547+
548+
async def _restore_from_backup(self, ctx: Context | None = None) -> None:
549+
"""Restore vector database from backup after a write failure."""
550+
backup_path = self._backup_path
551+
if not backup_path.exists():
552+
await log_warning("No backup found to restore from.", ctx)
553+
return
554+
try:
555+
shutil.rmtree(self._vector_db_path, ignore_errors=True)
556+
shutil.copytree(backup_path, self._vector_db_path)
557+
SharedSystemClient.clear_system_cache()
558+
self.chroma_client = chromadb.PersistentClient(path=str(self._vector_db_path))
559+
self.collection = self.chroma_client.get_or_create_collection("holoviz_docs", configuration=_CROMA_CONFIGURATION)
560+
await log_info("Restored vector database from backup.", ctx)
561+
except (KeyboardInterrupt, SystemExit):
562+
raise
563+
except BaseException as e:
564+
logger.error("Failed to restore from backup (%s: %s). Database may be degraded.", type(e).__name__, e)
565+
530566
def is_indexed(self) -> bool:
531567
"""Check if documentation index exists and is valid."""
532568
try:
533569
count = self.collection.count()
534570
return count > 0
535-
except Exception:
571+
except (KeyboardInterrupt, SystemExit):
572+
raise
573+
except BaseException:
536574
return False
537575

538576
async def ensure_indexed(self, ctx: Context | None = None):
@@ -887,6 +925,16 @@ async def index_documentation(self, ctx: Context | None = None):
887925
# Validate for duplicate IDs and log details
888926
await self._validate_unique_ids(all_docs)
889927

928+
# Create pre-write backup of vector database
929+
backup_path = self._backup_path
930+
try:
931+
if self._vector_db_path.exists():
932+
t0 = time.monotonic()
933+
shutil.copytree(self._vector_db_path, backup_path, dirs_exist_ok=True)
934+
await log_info(f"Pre-write backup created at {backup_path} ({time.monotonic() - t0:.1f}s)", ctx)
935+
except Exception as e:
936+
await log_warning(f"Failed to create pre-write backup: {e}. Continuing without backup.", ctx)
937+
890938
# Clear existing collection
891939
await log_info("Clearing existing index...", ctx)
892940

@@ -911,23 +959,30 @@ async def index_documentation(self, ctx: Context | None = None):
911959
# Add documents to ChromaDB
912960
await log_info(f"Adding {len(all_docs)} documents to index...", ctx)
913961

914-
self.collection.add(
915-
documents=[doc["content"] for doc in all_docs],
916-
metadatas=[
917-
{
918-
"title": doc["title"],
919-
"url": doc["url"],
920-
"project": doc["project"],
921-
"source_path": doc["source_path"],
922-
"source_path_stem": doc["source_path_stem"],
923-
"source_url": doc["source_url"],
924-
"description": doc["description"],
925-
"is_reference": doc["is_reference"],
926-
}
927-
for doc in all_docs
928-
],
929-
ids=[doc["id"] for doc in all_docs],
930-
)
962+
try:
963+
self.collection.add(
964+
documents=[doc["content"] for doc in all_docs],
965+
metadatas=[
966+
{
967+
"title": doc["title"],
968+
"url": doc["url"],
969+
"project": doc["project"],
970+
"source_path": doc["source_path"],
971+
"source_path_stem": doc["source_path_stem"],
972+
"source_url": doc["source_url"],
973+
"description": doc["description"],
974+
"is_reference": doc["is_reference"],
975+
}
976+
for doc in all_docs
977+
],
978+
ids=[doc["id"] for doc in all_docs],
979+
)
980+
except (KeyboardInterrupt, SystemExit):
981+
raise
982+
except BaseException as e:
983+
logger.error("ChromaDB write failed (%s: %s). Attempting restore from backup.", type(e).__name__, e)
984+
await self._restore_from_backup(ctx)
985+
raise
931986

932987
await log_info(f"✅ Successfully indexed {len(all_docs)} documents", ctx)
933988
await log_info(f"📊 Vector database stored at: {self._vector_db_path}", ctx)

tests/docs_mcp/test_data.py

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
from pathlib import Path
22

3+
import chromadb
34
import pytest
45
from pydantic import AnyHttpUrl
56

@@ -472,3 +473,106 @@ def test_truncate_content_with_special_characters():
472473
# Should handle special characters gracefully
473474
assert result is not None
474475
assert "foo-bar" in result or "baz_qux" in result or "Start text" in result
476+
477+
478+
# --- ChromaDB health check and backup tests ---
479+
480+
481+
def test_init_health_check_recovers_from_corrupt_db(tmp_path):
482+
"""ChromaDB init recovers from a corrupt database by wiping and reinitializing."""
483+
from chromadb.api.shared_system_client import SharedSystemClient
484+
485+
vector_dir = tmp_path / "chroma"
486+
vector_dir.mkdir()
487+
488+
# Write a corrupt chroma.sqlite3 to trigger an init failure
489+
(vector_dir / "chroma.sqlite3").write_text("THIS IS NOT A VALID SQLITE FILE")
490+
491+
# Clear cached ChromaDB state from other tests to avoid stale references
492+
SharedSystemClient.clear_system_cache()
493+
494+
indexer = DocumentationIndexer(data_dir=tmp_path, repos_dir=tmp_path / "repos", vector_dir=vector_dir)
495+
496+
# Should have recovered: collection exists and is empty
497+
assert indexer.collection.count() == 0
498+
499+
500+
def test_init_health_check_reraises_keyboard_interrupt(tmp_path):
501+
"""KeyboardInterrupt during ChromaDB init is not swallowed."""
502+
from unittest.mock import patch
503+
504+
vector_dir = tmp_path / "chroma"
505+
vector_dir.mkdir()
506+
507+
with patch("chromadb.PersistentClient", side_effect=KeyboardInterrupt):
508+
with pytest.raises(KeyboardInterrupt):
509+
DocumentationIndexer(data_dir=tmp_path, repos_dir=tmp_path / "repos", vector_dir=vector_dir)
510+
511+
512+
def test_is_indexed_catches_base_exception(tmp_path):
513+
"""is_indexed() returns False when collection.count() raises a BaseException subclass."""
514+
from unittest.mock import PropertyMock
515+
from unittest.mock import patch
516+
517+
indexer = DocumentationIndexer(data_dir=tmp_path, repos_dir=tmp_path / "repos", vector_dir=tmp_path / "chroma")
518+
519+
# Simulate a PanicException (BaseException subclass) from ChromaDB's Rust backend
520+
class FakePanicException(BaseException):
521+
pass
522+
523+
with patch.object(type(indexer.collection), "count", new_callable=PropertyMock) as mock_count:
524+
mock_count.return_value = FakePanicException("rust panic")
525+
# Replace count as a regular method that raises
526+
indexer.collection.count = lambda: (_ for _ in ()).throw(FakePanicException("rust panic")) # type: ignore[assignment]
527+
assert indexer.is_indexed() is False
528+
529+
530+
def test_backup_path_property(tmp_path):
531+
"""_backup_path returns the correct sibling path with .bak suffix."""
532+
indexer = DocumentationIndexer(data_dir=tmp_path, repos_dir=tmp_path / "repos", vector_dir=tmp_path / "chroma")
533+
assert indexer._backup_path == tmp_path / "chroma.bak"
534+
535+
536+
@pytest.mark.asyncio
537+
async def test_restore_from_backup_on_write_failure(tmp_path):
538+
"""On write failure, _restore_from_backup recovers original data."""
539+
import shutil
540+
541+
from chromadb.api.shared_system_client import SharedSystemClient
542+
543+
vector_dir = tmp_path / "chroma"
544+
SharedSystemClient.clear_system_cache()
545+
indexer = DocumentationIndexer(data_dir=tmp_path, repos_dir=tmp_path / "repos", vector_dir=vector_dir)
546+
547+
# Seed the collection with one document
548+
indexer.collection.add(documents=["hello world"], metadatas=[{"project": "test"}], ids=["doc1"])
549+
assert indexer.collection.count() == 1
550+
551+
# Force-flush by recreating the client so all WAL data is checkpointed
552+
del indexer.chroma_client
553+
SharedSystemClient.clear_system_cache()
554+
client = chromadb.PersistentClient(path=str(vector_dir))
555+
assert client.get_or_create_collection("holoviz_docs").count() == 1
556+
del client
557+
SharedSystemClient.clear_system_cache()
558+
559+
# Create a backup (simulating what index_documentation does)
560+
backup_path = indexer._backup_path
561+
shutil.copytree(vector_dir, backup_path, dirs_exist_ok=True)
562+
563+
# Recreate indexer to get fresh client after backup
564+
indexer = DocumentationIndexer(data_dir=tmp_path, repos_dir=tmp_path / "repos", vector_dir=vector_dir)
565+
566+
# Now corrupt the live database by clearing and adding a bad doc
567+
indexer.collection.delete(ids=["doc1"])
568+
indexer.collection.add(documents=["corrupted"], metadatas=[{"project": "bad"}], ids=["bad1"])
569+
assert indexer.collection.count() == 1
570+
571+
# Restore from backup
572+
await indexer._restore_from_backup()
573+
574+
# After restore, should have the original document back
575+
assert indexer.collection.count() == 1
576+
result = indexer.collection.get(ids=["doc1"])
577+
assert result["ids"] == ["doc1"]
578+
assert result["documents"] == ["hello world"]

0 commit comments

Comments
 (0)