Skip to content

Commit 1fc58aa

Browse files
test: concurrency suite for search-index rebuild, pointer swap, and query
1 parent bcde11d commit 1fc58aa

1 file changed

Lines changed: 220 additions & 0 deletions

File tree

Lines changed: 220 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,220 @@
1+
"""Concurrency tests for search-index rebuild, pointer swap, and query."""
2+
3+
from __future__ import annotations
4+
5+
import json
6+
import sqlite3
7+
import threading
8+
from contextlib import contextmanager
9+
from pathlib import Path
10+
from unittest.mock import patch
11+
12+
import pytest
13+
14+
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+
17+
_CONCURRENT_ROUNDS = 30
18+
_READER_THREADS = 4
19+
_BARRIER_TIMEOUT_S = 45.0
20+
_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),)
32+
33+
34+
@pytest.fixture
35+
def indexed_tree(tmp_path, monkeypatch):
36+
cache_root = tmp_path / "cache"
37+
cache_root.mkdir()
38+
projects = tmp_path / "projects"
39+
session_path = projects / "demo-proj" / "session_alpha.jsonl"
40+
term = "indexed-unique-sentinel"
41+
_write_session(
42+
session_path,
43+
[
44+
{
45+
"type": "user",
46+
"timestamp": "2026-05-19T10:00:00Z",
47+
"message": {"content": [{"type": "text", "text": f"find {term}"}]},
48+
},
49+
],
50+
)
51+
monkeypatch.delenv("CLAUDE_CODE_CHAT_BROWSER_NO_SEARCH_INDEX", raising=False)
52+
monkeypatch.setenv("CLAUDE_CODE_CHAT_BROWSER_SEARCH_INDEX_DIR", str(cache_root))
53+
reset_background_for_tests()
54+
55+
patches = _index_patches(cache_root)
56+
with patches[0]:
57+
assert build_search_index(str(projects), [], force=True) is True
58+
yield {
59+
"projects": str(projects),
60+
"cache_root": cache_root,
61+
"term": term,
62+
}
63+
64+
65+
def _assert_sentinel_reader_result(term: str, result: dict[str, object]) -> None:
66+
if result["index_locked"]:
67+
return
68+
if not result["query_ok"]:
69+
return
70+
assert result["hits"], (
71+
"query_ok with zero hits for a unique indexed sentinel during concurrent rebuild"
72+
)
73+
assert any(term in (hit["text"] or "") for hit in result["hits"])
74+
75+
76+
class TestSearchIndexConcurrency:
77+
def test_concurrent_rebuild_and_query(self, indexed_tree) -> None:
78+
errors: list[BaseException] = []
79+
barrier = threading.Barrier(_READER_THREADS + 1, timeout=_BARRIER_TIMEOUT_S)
80+
stop = threading.Event()
81+
patches = _index_patches(indexed_tree["cache_root"])
82+
83+
def reader() -> None:
84+
try:
85+
barrier.wait(timeout=_BARRIER_TIMEOUT_S)
86+
while not stop.is_set():
87+
result = query_index_hits(
88+
indexed_tree["term"],
89+
since_ms=None,
90+
max_results=10,
91+
)
92+
_assert_sentinel_reader_result(indexed_tree["term"], result)
93+
except BaseException as exc:
94+
errors.append(exc)
95+
96+
def writer() -> None:
97+
try:
98+
barrier.wait(timeout=_BARRIER_TIMEOUT_S)
99+
for _ in range(_CONCURRENT_ROUNDS):
100+
if stop.is_set():
101+
break
102+
build_search_index(indexed_tree["projects"], [], force=True)
103+
except BaseException as exc:
104+
errors.append(exc)
105+
finally:
106+
stop.set()
107+
108+
with patches[0]:
109+
threads = [
110+
threading.Thread(target=reader, name=f"reader-{i}")
111+
for i in range(_READER_THREADS)
112+
]
113+
threads.append(threading.Thread(target=writer, name="writer"))
114+
for thread in threads:
115+
thread.start()
116+
for thread in threads:
117+
thread.join(timeout=_BARRIER_TIMEOUT_S + 60.0)
118+
assert not thread.is_alive(), f"{thread.name} did not finish (possible deadlock)"
119+
assert not errors, errors
120+
121+
def test_operational_error_sets_index_locked(self, indexed_tree) -> None:
122+
@contextmanager
123+
def _locked_conn(*, readonly: bool = True):
124+
class _Conn:
125+
def execute(self, *args: object, **kwargs: object) -> None:
126+
raise sqlite3.OperationalError("database is locked")
127+
128+
def close(self) -> None:
129+
pass
130+
131+
yield _Conn()
132+
133+
patches = _index_patches(indexed_tree["cache_root"])
134+
with (
135+
patches[0],
136+
patch("utils.search_index._index_db_conn", _locked_conn),
137+
):
138+
result = query_index_hits(indexed_tree["term"], since_ms=None, max_results=5)
139+
assert result["index_locked"] is True
140+
assert result["query_ok"] is False
141+
assert result["hits"] == []
142+
143+
def test_absent_term_not_confused_with_locked(self, indexed_tree) -> None:
144+
patches = _index_patches(indexed_tree["cache_root"])
145+
with patches[0]:
146+
result = query_index_hits(_ABSENT_TERM, since_ms=None, max_results=10)
147+
assert result["index_locked"] is False
148+
assert result["query_ok"] is True
149+
assert result["hits"] == []
150+
151+
def test_index_locked_without_hits_falls_back_to_live_scan(self, indexed_tree) -> None:
152+
patches = _index_patches(indexed_tree["cache_root"])
153+
locked_payload = {
154+
"hits": [],
155+
"query_ok": False,
156+
"sql_rows_fetched": 0,
157+
"sql_exhausted": True,
158+
"index_locked": True,
159+
}
160+
with (
161+
patches[0],
162+
patch("api.search.query_index_hits", return_value=locked_payload),
163+
):
164+
hits = _resolve_search_results(
165+
indexed_tree["projects"],
166+
[],
167+
indexed_tree["term"],
168+
indexed_tree["term"],
169+
since_ms=None,
170+
max_results=50,
171+
)
172+
assert len(hits) >= 1
173+
174+
def test_search_via_index_returns_partial_hits_when_locked_after_batch(self, indexed_tree) -> None:
175+
patches = _index_patches(indexed_tree["cache_root"])
176+
fake_hit = {
177+
"session_id": "session_alpha",
178+
"project_name": "demo-proj",
179+
"title": "t",
180+
"role": "user",
181+
"timestamp": "2026-05-19T10:00:00Z",
182+
"text": indexed_tree["term"],
183+
"file_path": "/x",
184+
"mtime": 1.0,
185+
}
186+
calls = {"n": 0}
187+
188+
def _query_side_effect(*args: object, **kwargs: object) -> dict[str, object]:
189+
calls["n"] += 1
190+
if calls["n"] == 1:
191+
return {
192+
"hits": [fake_hit],
193+
"query_ok": True,
194+
"sql_rows_fetched": 1,
195+
"sql_exhausted": False,
196+
"index_locked": False,
197+
}
198+
return {
199+
"hits": [],
200+
"query_ok": False,
201+
"sql_rows_fetched": 0,
202+
"sql_exhausted": True,
203+
"index_locked": True,
204+
}
205+
206+
with (
207+
patches[0],
208+
patch("api.search.query_index_hits", side_effect=_query_side_effect),
209+
):
210+
outcome = _search_via_index(
211+
indexed_tree["projects"],
212+
[],
213+
indexed_tree["term"],
214+
indexed_tree["term"],
215+
since_ms=None,
216+
max_results=5,
217+
)
218+
assert outcome.hits is not None
219+
assert len(outcome.hits) == 1
220+
assert outcome.index_locked_without_hits is False

0 commit comments

Comments
 (0)