22
33from __future__ import annotations
44
5- import json
65import sqlite3
76import threading
7+ import time
8+ from collections .abc import Sequence
89from contextlib import contextmanager
910from pathlib import Path
1011from unittest .mock import patch
1112
1213import pytest
1314
1415from api .search import _resolve_search_results , _search_via_index
15- from utils .search_index import build_search_index , query_index_hits , reset_background_for_tests
16+ from tests .test_search_index import _index_patches , _write_session
17+ from utils .search_index import (
18+ build_search_index ,
19+ query_index_hits ,
20+ reset_background_for_tests ,
21+ start_search_index_background ,
22+ )
1623
1724_CONCURRENT_ROUNDS = 30
1825_READER_THREADS = 4
1926_BARRIER_TIMEOUT_S = 45.0
2027_ABSENT_TERM = "concurrency-absent-token-xyzzy-999"
21-
22-
23- def _write_session (path : Path , lines : list [dict [str , object ]]) -> None :
24- path .parent .mkdir (parents = True , exist_ok = True )
25- with path .open ("w" , encoding = "utf-8" ) as handle :
26- for line in lines :
27- handle .write (json .dumps (line , ensure_ascii = False ) + "\n " )
28-
29-
30- def _index_patches (cache_root : Path ):
31- return (patch ("utils.search_index.cache_dir" , return_value = cache_root ),)
28+ _BACKGROUND_POLL_S = 1
3229
3330
3431@pytest .fixture
@@ -73,17 +70,85 @@ def _assert_sentinel_reader_result(term: str, result: dict[str, object]) -> None
7370 assert any (term in (hit ["text" ] or "" ) for hit in result ["hits" ])
7471
7572
73+ def _lock_events_satisfy_documented_contract (
74+ events : Sequence [tuple [str , str ]],
75+ ) -> bool :
76+ """True when acquisition order matches docs/architecture.md lock contract."""
77+ held : set [str ] = set ()
78+ build_depth = 0
79+ for name , action in events :
80+ if action == "acquire" :
81+ if name == "index" :
82+ return False
83+ if name == "usability" and "build" not in held :
84+ return False
85+ held .add (name )
86+ if name == "build" :
87+ build_depth += 1
88+ elif action == "release" :
89+ if name not in held :
90+ return False
91+ held .remove (name )
92+ if name == "build" :
93+ build_depth -= 1
94+ return build_depth == 0 and not held
95+
96+
97+ def _record_lock_events_during_build (
98+ cache_root ,
99+ projects : str ,
100+ ) -> list [tuple [str , str ]]:
101+ import utils .search_index as si
102+
103+ events : list [tuple [str , str ]] = []
104+
105+ class _TrackedLock :
106+ def __init__ (self , name : str ) -> None :
107+ self ._name = name
108+ self ._inner = threading .Lock ()
109+
110+ def acquire (self , blocking : bool = True , timeout : float = - 1 ) -> bool :
111+ events .append ((self ._name , "acquire" ))
112+ return self ._inner .acquire (blocking , timeout )
113+
114+ def release (self ) -> None :
115+ events .append ((self ._name , "release" ))
116+ self ._inner .release ()
117+
118+ def __enter__ (self ) -> _TrackedLock :
119+ self .acquire ()
120+ return self
121+
122+ def __exit__ (self , * args : object ) -> None :
123+ self .release ()
124+
125+ saved_build = si ._index_build_lock
126+ saved_usability = si ._usability_cache_lock
127+ saved_index = si ._index_lock
128+ si ._index_build_lock = _TrackedLock ("build" )
129+ si ._usability_cache_lock = _TrackedLock ("usability" )
130+ si ._index_lock = _TrackedLock ("index" )
131+ patches = _index_patches (cache_root )
132+ try :
133+ with patches [0 ]:
134+ build_search_index (projects , [], force = True )
135+ finally :
136+ si ._index_build_lock = saved_build
137+ si ._usability_cache_lock = saved_usability
138+ si ._index_lock = saved_index
139+ return events
140+
141+
76142class TestSearchIndexConcurrency :
77143 def test_concurrent_rebuild_and_query (self , indexed_tree ) -> None :
78144 errors : list [BaseException ] = []
79145 barrier = threading .Barrier (_READER_THREADS + 1 , timeout = _BARRIER_TIMEOUT_S )
80- stop = threading .Event ()
81146 patches = _index_patches (indexed_tree ["cache_root" ])
82147
83148 def reader () -> None :
84149 try :
85- barrier . wait ( timeout = _BARRIER_TIMEOUT_S )
86- while not stop . is_set ():
150+ for _ in range ( _CONCURRENT_ROUNDS ):
151+ barrier . wait ( timeout = _BARRIER_TIMEOUT_S )
87152 result = query_index_hits (
88153 indexed_tree ["term" ],
89154 since_ms = None ,
@@ -95,15 +160,11 @@ def reader() -> None:
95160
96161 def writer () -> None :
97162 try :
98- barrier .wait (timeout = _BARRIER_TIMEOUT_S )
99163 for _ in range (_CONCURRENT_ROUNDS ):
100- if stop .is_set ():
101- break
164+ barrier .wait (timeout = _BARRIER_TIMEOUT_S )
102165 build_search_index (indexed_tree ["projects" ], [], force = True )
103166 except BaseException as exc :
104167 errors .append (exc )
105- finally :
106- stop .set ()
107168
108169 with patches [0 ]:
109170 threads = [
@@ -114,10 +175,87 @@ def writer() -> None:
114175 for thread in threads :
115176 thread .start ()
116177 for thread in threads :
117- thread .join (timeout = _BARRIER_TIMEOUT_S + 60 .0 )
178+ thread .join (timeout = _BARRIER_TIMEOUT_S + 90 .0 )
118179 assert not thread .is_alive (), f"{ thread .name } did not finish (possible deadlock)"
119180 assert not errors , errors
120181
182+ def test_queries_during_background_refresh (self , indexed_tree ) -> None :
183+ errors : list [BaseException ] = []
184+ stop = threading .Event ()
185+ projects = indexed_tree ["projects" ]
186+ patches = _index_patches (indexed_tree ["cache_root" ])
187+
188+ def reader () -> None :
189+ try :
190+ while not stop .is_set ():
191+ result = query_index_hits (
192+ indexed_tree ["term" ],
193+ since_ms = None ,
194+ max_results = 10 ,
195+ )
196+ _assert_sentinel_reader_result (indexed_tree ["term" ], result )
197+ time .sleep (0.02 )
198+ except BaseException as exc :
199+ errors .append (exc )
200+
201+ def mutator () -> None :
202+ try :
203+ for i in range (5 ):
204+ if stop .is_set ():
205+ break
206+ session_path = (
207+ Path (projects ) / "demo-proj" / f"session_bg_{ i } .jsonl"
208+ )
209+ _write_session (
210+ session_path ,
211+ [
212+ {
213+ "type" : "user" ,
214+ "timestamp" : "2026-05-19T10:00:00Z" ,
215+ "message" : {
216+ "content" : [
217+ {
218+ "type" : "text" ,
219+ "text" : f"find { indexed_tree ['term' ]} bg{ i } " ,
220+ }
221+ ]
222+ },
223+ },
224+ ],
225+ )
226+ time .sleep (0.15 )
227+ except BaseException as exc :
228+ errors .append (exc )
229+ finally :
230+ stop .set ()
231+
232+ with patches [0 ]:
233+ reset_background_for_tests ()
234+ start_search_index_background (projects , [], poll_seconds = _BACKGROUND_POLL_S )
235+ threads = [
236+ threading .Thread (target = reader , name = "reader" ),
237+ threading .Thread (target = mutator , name = "mutator" ),
238+ ]
239+ for thread in threads :
240+ thread .start ()
241+ for thread in threads :
242+ thread .join (timeout = 8.0 )
243+ assert not thread .is_alive ()
244+ reset_background_for_tests ()
245+ assert not errors , errors
246+
247+ def test_documented_lock_order_during_build (self , indexed_tree ) -> None :
248+ events = _record_lock_events_during_build (
249+ indexed_tree ["cache_root" ],
250+ indexed_tree ["projects" ],
251+ )
252+ assert events , "expected lock events during build_search_index"
253+ assert _lock_events_satisfy_documented_contract (events )
254+
255+ def test_lock_order_invariant_rejects_forbidden_nesting (self ) -> None :
256+ forbidden = [("usability" , "acquire" ), ("build" , "acquire" )]
257+ assert not _lock_events_satisfy_documented_contract (forbidden )
258+
121259 def test_operational_error_sets_index_locked (self , indexed_tree ) -> None :
122260 @contextmanager
123261 def _locked_conn (* , readonly : bool = True ):
0 commit comments