55import logging
66import os
77from datetime import datetime , timezone
8- from typing import Any
8+ from typing import Any , NamedTuple
99
1010from flask import Blueprint , current_app , request
1111
3636_MAX_SEARCH_SINCE_DAYS = 36_500
3737
3838
39+ class _IndexSearchOutcome (NamedTuple ):
40+ hits : list [SearchHitDict ] | None
41+ fts_exhausted : bool
42+ index_locked_without_hits : bool = False
43+
44+
45+ class _SearchIndexUnavailableError (Exception ):
46+ """Index locked with no partial hits and live-scan fallback failed."""
47+
48+
3949def _parse_limit (raw : str | None , default : int = _DEFAULT_LIMIT ) -> int :
4050 """Parse a positive integer limit from a query string value."""
4151 if raw is None or raw .strip () == "" :
@@ -145,9 +155,9 @@ def _search_via_index(
145155 * ,
146156 since_ms : int | None ,
147157 max_results : int ,
148- ) -> tuple [ list [ SearchHitDict ] | None , bool ] :
158+ ) -> _IndexSearchOutcome :
149159 if not index_search_enabled () or not index_is_usable (projects_dir , rules ):
150- return None , False
160+ return _IndexSearchOutcome ( None , False )
151161
152162 rules_fp = rules_fingerprint (rules )
153163 results : list [SearchHitDict ] = []
@@ -167,10 +177,10 @@ def _search_via_index(
167177 len (results ),
168178 )
169179 if results :
170- return _rank_search_hits (results )[:max_results ], False
171- return None , False
180+ return _IndexSearchOutcome ( _rank_search_hits (results )[:max_results ], False )
181+ return _IndexSearchOutcome ( None , False , index_locked_without_hits = True )
172182 if not indexed ["query_ok" ]:
173- return None , False
183+ return _IndexSearchOutcome ( None , False )
174184 if indexed ["sql_rows_fetched" ] == 0 :
175185 fts_exhausted = indexed ["sql_exhausted" ]
176186 break
@@ -201,7 +211,36 @@ def _search_via_index(
201211 if indexed ["sql_exhausted" ]:
202212 fts_exhausted = True
203213 break
204- return _rank_search_hits (results )[:max_results ], fts_exhausted
214+ return _IndexSearchOutcome (_rank_search_hits (results )[:max_results ], fts_exhausted )
215+
216+
217+ def _live_scan_with_index_lock_fallback (
218+ base : str ,
219+ rules : list [Any ],
220+ query : str ,
221+ query_lower : str ,
222+ * ,
223+ since_ms : int | None ,
224+ max_results : int ,
225+ index_locked_without_hits : bool ,
226+ ) -> list [SearchHitDict ]:
227+ try :
228+ return _search_live_scan (
229+ base ,
230+ rules ,
231+ query ,
232+ query_lower ,
233+ since_ms = since_ms ,
234+ max_results = max_results ,
235+ )
236+ except Exception :
237+ if index_locked_without_hits :
238+ _logger .warning (
239+ "Search index locked; live-scan fallback failed" ,
240+ exc_info = True ,
241+ )
242+ raise _SearchIndexUnavailableError from None
243+ raise
205244
206245
207246def _resolve_search_results (
@@ -213,36 +252,38 @@ def _resolve_search_results(
213252 since_ms : int | None ,
214253 max_results : int ,
215254) -> list [SearchHitDict ]:
216- indexed , _fts_exhausted = _search_via_index (
255+ outcome = _search_via_index (
217256 base ,
218257 rules ,
219258 query ,
220259 query_lower ,
221260 since_ms = since_ms ,
222261 max_results = max_results ,
223262 )
224- if indexed is None :
225- return _search_live_scan (
263+ if outcome . hits is None :
264+ return _live_scan_with_index_lock_fallback (
226265 base ,
227266 rules ,
228267 query ,
229268 query_lower ,
230269 since_ms = since_ms ,
231270 max_results = max_results ,
271+ index_locked_without_hits = outcome .index_locked_without_hits ,
232272 )
233273
234- if len (indexed ) >= max_results :
235- return indexed
274+ if len (outcome . hits ) >= max_results :
275+ return outcome . hits
236276
237- live = _search_live_scan (
277+ live = _live_scan_with_index_lock_fallback (
238278 base ,
239279 rules ,
240280 query ,
241281 query_lower ,
242282 since_ms = since_ms ,
243283 max_results = max_results ,
284+ index_locked_without_hits = False ,
244285 )
245- return _merge_search_hits (indexed , live , max_results = max_results )
286+ return _merge_search_hits (outcome . hits , live , max_results = max_results )
246287
247288
248289def _search_live_scan (
@@ -359,6 +400,12 @@ def search() -> FlaskReturn:
359400 max_results = max_results ,
360401 )
361402 )
403+ except _SearchIndexUnavailableError :
404+ return error_response (
405+ ErrorCode .SEARCH_INDEX_UNAVAILABLE ,
406+ "Search index is temporarily unavailable" ,
407+ 503 ,
408+ )
362409 except Exception :
363410 _logger .exception ("Unexpected error during search" )
364411 return error_response (
0 commit comments