1313import pytest
1414
1515from api .search import _resolve_search_results , _search_via_index
16- from tests .test_search_index import _index_patches , _write_session
16+ from tests .conftest import index_patches as _index_patches , write_session as _write_session
1717from utils .search_index import (
1818 IndexQueryResult ,
1919 build_search_index ,
20+ ensure_search_index ,
2021 query_index_hits ,
2122 reset_background_for_tests ,
2223 start_search_index_background ,
2728_BARRIER_TIMEOUT_S = 45.0
2829_ABSENT_TERM = "concurrency-absent-token-xyzzy-999"
2930_BACKGROUND_POLL_S = 1
31+ _BACKGROUND_MUTATOR_ITERATIONS = 12
32+ _BACKGROUND_MUTATOR_SLEEP_S = 0.25
33+ _BACKGROUND_THREAD_JOIN_TIMEOUT_S = 12.0
34+ _BACKGROUND_REFRESH_WAIT_S = 8.0
35+ _MIN_BACKGROUND_REFRESHES = 2
36+
37+ # A locked index reports no hits with query_ok False; used to drive the
38+ # live-scan fallback paths in api.search.
39+ _LOCKED_PAYLOAD : IndexQueryResult = {
40+ "hits" : [],
41+ "query_ok" : False ,
42+ "sql_rows_fetched" : 0 ,
43+ "sql_exhausted" : True ,
44+ "index_locked" : True ,
45+ }
46+
47+
48+ @pytest .fixture (autouse = True )
49+ def _isolate_search_index_background_worker ():
50+ reset_background_for_tests ()
51+ yield
52+ reset_background_for_tests ()
3053
3154
3255@pytest .fixture
@@ -185,6 +208,15 @@ def test_queries_during_background_refresh(self, indexed_tree) -> None:
185208 projects = indexed_tree ["projects" ]
186209 patches = _index_patches (indexed_tree ["cache_root" ])
187210
211+ refresh_lock = threading .Lock ()
212+ refresh_count = 0
213+
214+ def _counting_ensure (* args : object , ** kwargs : object ) -> None :
215+ nonlocal refresh_count
216+ with refresh_lock :
217+ refresh_count += 1
218+ ensure_search_index (* args , ** kwargs ) # type: ignore[arg-type]
219+
188220 def reader () -> None :
189221 try :
190222 while not stop .is_set ():
@@ -200,7 +232,7 @@ def reader() -> None:
200232
201233 def mutator () -> None :
202234 try :
203- for i in range (5 ):
235+ for i in range (_BACKGROUND_MUTATOR_ITERATIONS ):
204236 if stop .is_set ():
205237 break
206238 session_path = Path (projects ) / "demo-proj" / f"session_bg_{ i } .jsonl"
@@ -221,25 +253,57 @@ def mutator() -> None:
221253 },
222254 ],
223255 )
224- time .sleep (0.15 )
256+ time .sleep (_BACKGROUND_MUTATOR_SLEEP_S )
225257 except BaseException as exc :
226258 errors .append (exc )
227259 finally :
228260 stop .set ()
229261
262+ def _bg_term_visible () -> bool :
263+ # A term only present in a mutator-written session becomes queryable
264+ # once the background worker rebuilds the index and swaps the pointer.
265+ result = query_index_hits (
266+ f"{ indexed_tree ['term' ]} bg0" ,
267+ since_ms = None ,
268+ max_results = 10 ,
269+ )
270+ return result ["query_ok" ] and bool (result ["hits" ])
271+
230272 with patches [0 ]:
231- reset_background_for_tests ()
232- start_search_index_background (projects , [], poll_seconds = _BACKGROUND_POLL_S )
233- threads = [
234- threading .Thread (target = reader , name = "reader" ),
235- threading .Thread (target = mutator , name = "mutator" ),
236- ]
237- for thread in threads :
238- thread .start ()
239- for thread in threads :
240- thread .join (timeout = 8.0 )
241- assert not thread .is_alive ()
242- reset_background_for_tests ()
273+ try :
274+ reset_background_for_tests ()
275+ with patch (
276+ "utils.search_index.ensure_search_index" ,
277+ side_effect = _counting_ensure ,
278+ ):
279+ start_search_index_background (projects , [], poll_seconds = _BACKGROUND_POLL_S )
280+ threads = [
281+ threading .Thread (target = reader , name = "reader" ),
282+ threading .Thread (target = mutator , name = "mutator" ),
283+ ]
284+ for thread in threads :
285+ thread .start ()
286+ for thread in threads :
287+ thread .join (timeout = _BACKGROUND_THREAD_JOIN_TIMEOUT_S )
288+ assert not thread .is_alive ()
289+
290+ deadline = time .monotonic () + _BACKGROUND_REFRESH_WAIT_S
291+ bg_visible = False
292+ while time .monotonic () < deadline :
293+ if _bg_term_visible ():
294+ bg_visible = True
295+ break
296+ time .sleep (0.05 )
297+
298+ assert bg_visible , "background worker never rebuilt index with new sessions"
299+ with refresh_lock :
300+ observed = refresh_count
301+ assert observed >= _MIN_BACKGROUND_REFRESHES , (
302+ f"expected at least { _MIN_BACKGROUND_REFRESHES } background "
303+ f"refreshes, observed { observed } "
304+ )
305+ finally :
306+ reset_background_for_tests ()
243307 assert not errors , errors
244308
245309 def test_documented_lock_order_during_build (self , indexed_tree ) -> None :
@@ -286,16 +350,9 @@ def test_absent_term_not_confused_with_locked(self, indexed_tree) -> None:
286350
287351 def test_index_locked_without_hits_falls_back_to_live_scan (self , indexed_tree ) -> None :
288352 patches = _index_patches (indexed_tree ["cache_root" ])
289- locked_payload = {
290- "hits" : [],
291- "query_ok" : False ,
292- "sql_rows_fetched" : 0 ,
293- "sql_exhausted" : True ,
294- "index_locked" : True ,
295- }
296353 with (
297354 patches [0 ],
298- patch ("api.search.query_index_hits" , return_value = locked_payload ),
355+ patch ("api.search.query_index_hits" , return_value = _LOCKED_PAYLOAD ),
299356 ):
300357 hits = _resolve_search_results (
301358 indexed_tree ["projects" ],
@@ -333,13 +390,7 @@ def _query_side_effect(*args: object, **kwargs: object) -> dict[str, object]:
333390 "sql_exhausted" : False ,
334391 "index_locked" : False ,
335392 }
336- return {
337- "hits" : [],
338- "query_ok" : False ,
339- "sql_rows_fetched" : 0 ,
340- "sql_exhausted" : True ,
341- "index_locked" : True ,
342- }
393+ return dict (_LOCKED_PAYLOAD )
343394
344395 with (
345396 patches [0 ],
0 commit comments