Skip to content

Commit b8d7e39

Browse files
test: exercise background rebuild and lock order in concurrency suite
1 parent 1fc58aa commit b8d7e39

1 file changed

Lines changed: 160 additions & 22 deletions

File tree

tests/test_search_index_concurrency.py

Lines changed: 160 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -2,33 +2,30 @@
22

33
from __future__ import annotations
44

5-
import json
65
import sqlite3
76
import threading
7+
import time
8+
from collections.abc import Sequence
89
from contextlib import contextmanager
910
from pathlib import Path
1011
from unittest.mock import patch
1112

1213
import pytest
1314

1415
from 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+
76142
class 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

Comments
 (0)