Skip to content

Commit b991260

Browse files
Merge pull request #101 from MarcSkovMadsen/copilot/sub-pr-100
Address code review feedback: fix exception handling, async lock initialization, and concurrency protection
2 parents 9e94b68 + 219a887 commit b991260

3 files changed

Lines changed: 74 additions & 55 deletions

File tree

src/holoviz_mcp/display_mcp/utils.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -151,7 +151,7 @@ def execute_in_module(
151151
try:
152152
exec(code, module.__dict__)
153153
return module.__dict__
154-
except:
154+
except Exception:
155155
if cleanup:
156156
sys.modules.pop(module_name, None)
157157
raise

src/holoviz_mcp/holoviz_mcp/data.py

Lines changed: 73 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -232,15 +232,38 @@ def __init__(self, *, data_dir: Optional[Path] = None, repos_dir: Optional[Path]
232232
self.chroma_client = chromadb.PersistentClient(path=str(vector_db_path))
233233
self.collection = self.chroma_client.get_or_create_collection("holoviz_docs", configuration=_CROMA_CONFIGURATION)
234234

235-
# Add async lock for database operations to prevent corruption from concurrent access
236-
self._db_lock = asyncio.Lock()
235+
# Lazy-initialized async lock for database operations to prevent corruption from concurrent access
236+
self._db_lock: Optional[asyncio.Lock] = None
237237

238238
# Initialize notebook converter
239239
self.nb_exporter = MarkdownExporter()
240240

241241
# Load documentation config from the centralized config system
242242
self.config = get_config().docs
243243

244+
# Wrap index_documentation to ensure all calls use the async DB lock
245+
# This prevents race conditions when indexing is called directly (e.g., from
246+
# update_index tool or run method) and concurrently with search operations
247+
if not hasattr(self, "_index_documentation_wrapped"):
248+
original_index_documentation = self.index_documentation
249+
250+
async def _locked_index_documentation(*args: Any, **kwargs: Any) -> Any:
251+
async with self.db_lock:
252+
return await original_index_documentation(*args, **kwargs)
253+
254+
self.index_documentation = _locked_index_documentation # type: ignore[method-assign]
255+
self._index_documentation_wrapped = True
256+
257+
@property
258+
def db_lock(self) -> asyncio.Lock:
259+
"""Lazy-initialize and return the database lock.
260+
261+
This ensures the lock is created in the correct event loop context.
262+
"""
263+
if self._db_lock is None:
264+
self._db_lock = asyncio.Lock()
265+
return self._db_lock
266+
244267
def is_indexed(self) -> bool:
245268
"""Check if documentation index exists and is valid."""
246269
try:
@@ -687,7 +710,7 @@ async def _validate_unique_ids(self, all_docs: list[dict[str, Any]], ctx: Contex
687710

688711
async def search_get_reference_guide(self, component: str, project: Optional[str] = None, content: bool = True, ctx: Context | None = None) -> list[Document]:
689712
"""Search for reference guides for a specific component."""
690-
async with self._db_lock:
713+
async with self.db_lock:
691714
await self.ensure_indexed()
692715

693716
# Build search strategies
@@ -738,63 +761,60 @@ async def search_get_reference_guide(self, component: str, project: Optional[str
738761

739762
async def search(self, query: str, project: Optional[str] = None, content: bool = True, max_results: int = 5, ctx: Context | None = None) -> list[Document]:
740763
"""Search the documentation using semantic similarity."""
741-
async with self._db_lock:
764+
async with self.db_lock:
742765
await self.ensure_indexed(ctx=ctx)
743766

744767
# Build where clause for filtering
745768
where_clause = {"project": str(project)} if project else None
746769

747-
try:
748-
# Perform vector similarity search
749-
results = self.collection.query(query_texts=[query], n_results=max_results, where=where_clause) # type: ignore[arg-type]
750-
751-
documents = []
752-
if results["ids"] and results["ids"][0]:
753-
for i, _ in enumerate(results["ids"][0]):
754-
if results["metadatas"] and results["metadatas"][0]:
755-
metadata = results["metadatas"][0][i]
756-
757-
# Include content if requested
758-
content_text = results["documents"][0][i] if (content and results["documents"]) else None
759-
760-
# Safe URL construction
761-
url_value = metadata.get("url", "https://example.com")
762-
if not url_value or url_value == "None" or not isinstance(url_value, str):
763-
url_value = "https://example.com"
764-
765-
# Safe relevance score calculation
766-
relevance_score = None
767-
if (
768-
results.get("distances")
769-
and isinstance(results["distances"], list)
770-
and len(results["distances"]) > 0
771-
and isinstance(results["distances"][0], list)
772-
and len(results["distances"][0]) > i
773-
):
774-
try:
775-
relevance_score = (2.0 - float(results["distances"][0][i])) / 2.0
776-
except (ValueError, TypeError):
777-
relevance_score = None
778-
779-
document = Document(
780-
title=str(metadata["title"]),
781-
url=HttpUrl(url_value),
782-
project=str(metadata["project"]),
783-
source_path=str(metadata["source_path"]),
784-
source_url=HttpUrl(str(metadata.get("source_url", ""))),
785-
description=str(metadata["description"]),
786-
is_reference=bool(metadata["is_reference"]),
787-
content=content_text,
788-
relevance_score=relevance_score,
789-
)
790-
documents.append(document)
791-
return documents
792-
except Exception as e:
793-
raise e
770+
# Perform vector similarity search
771+
results = self.collection.query(query_texts=[query], n_results=max_results, where=where_clause) # type: ignore[arg-type]
772+
773+
documents = []
774+
if results["ids"] and results["ids"][0]:
775+
for i, _ in enumerate(results["ids"][0]):
776+
if results["metadatas"] and results["metadatas"][0]:
777+
metadata = results["metadatas"][0][i]
778+
779+
# Include content if requested
780+
content_text = results["documents"][0][i] if (content and results["documents"]) else None
781+
782+
# Safe URL construction
783+
url_value = metadata.get("url", "https://example.com")
784+
if not url_value or url_value == "None" or not isinstance(url_value, str):
785+
url_value = "https://example.com"
786+
787+
# Safe relevance score calculation
788+
relevance_score = None
789+
if (
790+
results.get("distances")
791+
and isinstance(results["distances"], list)
792+
and len(results["distances"]) > 0
793+
and isinstance(results["distances"][0], list)
794+
and len(results["distances"][0]) > i
795+
):
796+
try:
797+
relevance_score = (2.0 - float(results["distances"][0][i])) / 2.0
798+
except (ValueError, TypeError):
799+
relevance_score = None
800+
801+
document = Document(
802+
title=str(metadata["title"]),
803+
url=HttpUrl(url_value),
804+
project=str(metadata["project"]),
805+
source_path=str(metadata["source_path"]),
806+
source_url=HttpUrl(str(metadata.get("source_url", ""))),
807+
description=str(metadata["description"]),
808+
is_reference=bool(metadata["is_reference"]),
809+
content=content_text,
810+
relevance_score=relevance_score,
811+
)
812+
documents.append(document)
813+
return documents
794814

795815
async def get_document(self, path: str, project: str, ctx: Context | None = None) -> Document:
796816
"""Get a specific document."""
797-
async with self._db_lock:
817+
async with self.db_lock:
798818
await self.ensure_indexed(ctx=ctx)
799819

800820
# Build where clause for filtering
@@ -859,7 +879,7 @@ async def list_projects(self) -> list[str]:
859879
list[str]: A list of project names that have documentation available.
860880
Names are returned in hyphenated format (e.g., "panel-material-ui").
861881
"""
862-
async with self._db_lock:
882+
async with self.db_lock:
863883
await self.ensure_indexed()
864884

865885
try:

tests/display_mcp/test_utils.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@
33
import sys
44

55
import panel as pn
6-
import param
76
import pytest
87

98
from holoviz_mcp.display_mcp.database import Snippet

0 commit comments

Comments
 (0)