@@ -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 :
0 commit comments