Skip to content

Commit 44a8f29

Browse files
fix: stoppable background worker and stronger concurrency test oracles
1 parent e20e37b commit 44a8f29

4 files changed

Lines changed: 159 additions & 94 deletions

File tree

tests/conftest.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,16 +2,32 @@
22

33
from __future__ import annotations
44

5+
import json
56
import os
67
import shutil
78
from collections.abc import Mapping
89
from pathlib import Path
10+
from unittest.mock import patch
911

1012
import pytest
1113
from hypothesis import settings
1214

1315
from app import create_app
1416

17+
18+
def write_session(path: Path, lines: list[dict[str, object]]) -> None:
19+
"""Write JSONL session *lines* to *path*, creating parent directories."""
20+
path.parent.mkdir(parents=True, exist_ok=True)
21+
with path.open("w", encoding="utf-8") as handle:
22+
for line in lines:
23+
handle.write(json.dumps(line, ensure_ascii=False) + "\n")
24+
25+
26+
def index_patches(cache_root: Path):
27+
"""Return the patch context managers that point the index at *cache_root*."""
28+
return (patch("utils.search_index.cache_dir", return_value=cache_root),)
29+
30+
1531
# Hypothesis profiles drive fuzz example counts/deadlines (deadline disabled to
1632
# avoid timing flakiness on slow/CI runners). CI runs fewer examples for speed.
1733
settings.register_profile("dev", max_examples=200, deadline=None)

tests/test_search_index.py

Lines changed: 5 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@
22

33
from __future__ import annotations
44

5-
import json
65
import sqlite3
76
from contextlib import closing, contextmanager
87
from datetime import UTC, datetime, timedelta
@@ -12,6 +11,7 @@
1211
import pytest
1312

1413
from api.search import _resolve_search_results, _search_via_index
14+
from tests.conftest import index_patches as _index_patches, write_session as _write_session
1515
from utils.exclusion_rules import load_rules
1616
from utils.search_index import (
1717
build_search_index,
@@ -26,17 +26,6 @@
2626
)
2727

2828

29-
def _write_session(path: Path, lines: list[dict[str, object]]) -> None:
30-
path.parent.mkdir(parents=True, exist_ok=True)
31-
with path.open("w", encoding="utf-8") as handle:
32-
for line in lines:
33-
handle.write(json.dumps(line, ensure_ascii=False) + "\n")
34-
35-
36-
def _index_patches(cache_root: Path):
37-
return (patch("utils.search_index.cache_dir", return_value=cache_root),)
38-
39-
4029
@pytest.fixture
4130
def indexed_tree(tmp_path, monkeypatch):
4231
"""Temp projects dir + isolated search index cache."""
@@ -381,11 +370,15 @@ def test_background_worker_starts_once(self, indexed_tree, monkeypatch):
381370
patches[0],
382371
patch("utils.search_index.threading.Thread") as mock_thread,
383372
):
373+
# The mock worker is not a real thread; report it as stopped so the
374+
# teardown reset does not treat it as a live worker that won't join.
375+
mock_thread.return_value.is_alive.return_value = False
384376
start_search_index_background(indexed_tree["projects"], [])
385377
start_search_index_background(indexed_tree["projects"], [])
386378
assert mock_thread.call_count == 1
387379
assert mock_thread.call_args.kwargs.get("daemon") is True
388380
assert mock_thread.return_value.start.call_count == 1
381+
reset_background_for_tests()
389382

390383

391384
class TestIndexSearchCompleteness:

tests/test_search_index_concurrency.py

Lines changed: 81 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -13,10 +13,11 @@
1313
import pytest
1414

1515
from 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
1717
from 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,
@@ -27,6 +28,28 @@
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

Comments
 (0)