Skip to content

Commit 493ee68

Browse files
fix search index edge cases from review
Clamp oversized since_days instead of dropping them back to the 30-day default. Keep paging FTS hits when exclusion rules filter out the first batch so limit still fills. Skip session files that vanish during the fingerprint walk, drop dead index path constants, and print the real regression limit in the benchmark gate. Refresh test_search_full_corpus baseline for the indexed search path.
1 parent 2bbdd7d commit 493ee68

7 files changed

Lines changed: 113 additions & 84 deletions

File tree

api/search.py

Lines changed: 40 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -54,9 +54,9 @@ def _parse_since_days(raw: str | None) -> int | None:
5454
days = int(str(raw).strip())
5555
except ValueError:
5656
return None
57-
if days <= 0 or days > _MAX_SEARCH_SINCE_DAYS:
57+
if days <= 0:
5858
return None
59-
return days
59+
return min(days, _MAX_SEARCH_SINCE_DAYS)
6060

6161

6262
def _message_searchable_text(msg: MessageDict) -> str:
@@ -109,32 +109,46 @@ def _search_via_index(
109109
return None
110110

111111
rules_fp = rules_fingerprint(rules)
112-
indexed = query_index_hits(query_lower, since_ms=since_ms, max_results=max_results)
113-
if not indexed["query_ok"]:
114-
return None
115-
116112
results: list[SearchHitDict] = []
117-
for hit in indexed["hits"]:
118-
if len(results) >= max_results:
119-
break
120-
if _index_hit_excluded(
121-
rules,
122-
rules_fp,
123-
project_name=hit["project_name"],
124-
file_path=hit["file_path"],
125-
mtime=hit["mtime"],
126-
):
127-
continue
128-
results.append(
129-
{
130-
"project": hit["project_name"],
131-
"session_id": hit["session_id"],
132-
"title": hit["title"],
133-
"role": hit["role"],
134-
"timestamp": hit["timestamp"],
135-
"snippet": search_snippet(hit["text"], query),
136-
}
113+
sql_offset = 0
114+
while len(results) < max_results:
115+
need = max_results - len(results)
116+
indexed = query_index_hits(
117+
query_lower,
118+
since_ms=since_ms,
119+
max_results=need,
120+
sql_offset=sql_offset,
137121
)
122+
if not indexed["query_ok"]:
123+
return None
124+
if indexed["sql_rows_fetched"] == 0:
125+
break
126+
127+
for hit in indexed["hits"]:
128+
if len(results) >= max_results:
129+
break
130+
if _index_hit_excluded(
131+
rules,
132+
rules_fp,
133+
project_name=hit["project_name"],
134+
file_path=hit["file_path"],
135+
mtime=hit["mtime"],
136+
):
137+
continue
138+
results.append(
139+
{
140+
"project": hit["project_name"],
141+
"session_id": hit["session_id"],
142+
"title": hit["title"],
143+
"role": hit["role"],
144+
"timestamp": hit["timestamp"],
145+
"snippet": search_snippet(hit["text"], query),
146+
}
147+
)
148+
149+
sql_offset += indexed["sql_rows_fetched"]
150+
if indexed["sql_exhausted"]:
151+
break
138152
return results
139153

140154

app.py

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -113,9 +113,7 @@ def create_app(
113113
projects_dir = base_dir or get_claude_projects_dir()
114114
start_search_index_background(projects_dir, app.config["EXCLUSION_RULES"])
115115
except Exception:
116-
logging.getLogger(__name__).exception(
117-
"Failed to start search index background worker"
118-
)
116+
logging.getLogger(__name__).exception("Failed to start search index background worker")
119117

120118
@app.after_request
121119
def set_security_headers(response):

benchmarks/baselines.json

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
2-
"_note": "Gated means from ubuntu-latest CI benchmark-results.json. PR #108 (schema drift): parse/export latency baselines raised for per-entry field-path fingerprinting. Excluded from gate (recorded for reference): test_parse_session_small (sub-ms CI noise). Memory benchmarks use extra_info.peak_bytes (bytes); latency uses stats.mean (seconds).",
3-
"updated": "2026-07-03T00:00:00Z",
2+
"_note": "Gated means from ubuntu-latest CI benchmark-results.json. PR #108 (schema drift): parse/export latency baselines raised for per-entry field-path fingerprinting. Excluded from gate (recorded for reference): test_parse_session_small (sub-ms CI noise). test_search_full_corpus baseline refreshed for FTS search path (PR #120). Memory benchmarks use extra_info.peak_bytes (bytes); latency uses stats.mean (seconds).",
3+
"updated": "2026-07-07T00:00:00Z",
44
"machine": "Linux",
55
"groups": {
66
"parse": {
@@ -18,7 +18,7 @@
1818
"test_bulk_export_zip_peak_memory[sessions-100]": 694088.0
1919
},
2020
"search": {
21-
"test_search_full_corpus": 0.0011120838654706596
21+
"test_search_full_corpus": 0.002595
2222
}
2323
}
2424
}

scripts/check_benchmark_regression.py

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,13 @@
1212
SEARCH_BENCHMARK_THRESHOLD = 2.0
1313
RELAXED_THRESHOLD_TESTS = frozenset({"test_search_full_corpus"})
1414

15+
16+
def benchmark_regression_limit(name: str, threshold: float = THRESHOLD) -> float:
17+
"""Return the allowed regression ratio for a gated benchmark."""
18+
if name in RELAXED_THRESHOLD_TESTS:
19+
return SEARCH_BENCHMARK_THRESHOLD
20+
return threshold
21+
1522
# Sub-ms timings are too noisy for a fixed 20% gate on ubuntu CI.
1623
EXCLUDED_FROM_GATE = frozenset(
1724
{
@@ -155,7 +162,7 @@ def check_regression(
155162
print(f"WARN: baseline for {name!r} is zero; skipping ratio check")
156163
continue
157164
ratio = cur / base
158-
limit = SEARCH_BENCHMARK_THRESHOLD if name in RELAXED_THRESHOLD_TESTS else threshold
165+
limit = benchmark_regression_limit(name, threshold)
159166
tag = "FAIL" if ratio > limit else "ok"
160167
entry = entries_by_name.get(name)
161168
if metric_is_bytes(name, entry):
@@ -172,7 +179,16 @@ def check_regression(
172179
print(f"WARN: {name!r} has no baseline yet; not gated")
173180

174181
if failures:
175-
print(f"\nREGRESSION: {len(failures)} benchmark(s) exceeded {threshold:.0%}")
182+
limits = {benchmark_regression_limit(name, threshold) for name in failures}
183+
if len(limits) == 1:
184+
(limit,) = limits
185+
print(f"\nREGRESSION: {len(failures)} benchmark(s) exceeded {limit:.0%}")
186+
else:
187+
details = ", ".join(
188+
f"{name} ({benchmark_regression_limit(name, threshold):.0%})"
189+
for name in failures
190+
)
191+
print(f"\nREGRESSION: {len(failures)} benchmark(s) exceeded limit: {details}")
176192
if missing:
177193
print(f"\nMISSING: {len(missing)} gated benchmark(s) absent from current results")
178194
if failures or missing:

tests/test_search.py

Lines changed: 2 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -87,14 +87,7 @@ def test_empty_query(client_single):
8787

8888

8989
def _index_patches(cache_root: Path):
90-
return (
91-
patch("utils.search_index.cache_dir", return_value=cache_root),
92-
patch(
93-
"utils.search_index.SEARCH_INDEX_POINTER_FILE",
94-
cache_root / "search_index.active",
95-
),
96-
patch("utils.search_index.SEARCH_INDEX_FILE", cache_root / "search_index.sqlite"),
97-
)
90+
return (patch("utils.search_index.cache_dir", return_value=cache_root),)
9891

9992

10093
def _seed_indexed_client(tmp_path, monkeypatch, *, timestamp: str):
@@ -115,7 +108,7 @@ def _seed_indexed_client(tmp_path, monkeypatch, *, timestamp: str):
115108
reset_background_for_tests()
116109

117110
patches = _index_patches(cache_root)
118-
with patches[0], patches[1], patches[2]:
111+
with patches[0]:
119112
assert build_search_index(str(tmp_path / "projects"), [], force=True) is True
120113

121114
app = create_app(base_dir=str(tmp_path / "projects"))

tests/test_search_index.py

Lines changed: 20 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -32,14 +32,7 @@ def _write_session(path: Path, lines: list[dict[str, object]]) -> None:
3232

3333

3434
def _index_patches(cache_root: Path):
35-
return (
36-
patch("utils.search_index.cache_dir", return_value=cache_root),
37-
patch(
38-
"utils.search_index.SEARCH_INDEX_POINTER_FILE",
39-
cache_root / "search_index.active",
40-
),
41-
patch("utils.search_index.SEARCH_INDEX_FILE", cache_root / "search_index.sqlite"),
42-
)
35+
return (patch("utils.search_index.cache_dir", return_value=cache_root),)
4336

4437

4538
@pytest.fixture
@@ -73,7 +66,7 @@ def indexed_tree(tmp_path, monkeypatch):
7366
reset_background_for_tests()
7467

7568
patches = _index_patches(cache_root)
76-
with patches[0], patches[1], patches[2]:
69+
with patches[0]:
7770
built = build_search_index(str(projects), [], force=True)
7871
assert built is True
7972
pointer = cache_root / "search_index.active"
@@ -104,12 +97,12 @@ def test_schema_tables_exist(self, indexed_tree):
10497

10598
def test_index_is_usable_after_build(self, indexed_tree):
10699
patches = _index_patches(indexed_tree["cache_root"])
107-
with patches[0], patches[1], patches[2]:
100+
with patches[0]:
108101
assert index_is_usable(indexed_tree["projects"], []) is True
109102

110103
def test_fts_finds_term(self, indexed_tree):
111104
patches = _index_patches(indexed_tree["cache_root"])
112-
with patches[0], patches[1], patches[2]:
105+
with patches[0]:
113106
result = query_index_hits(indexed_tree["term"], since_ms=None, max_results=10)
114107
assert result["query_ok"] is True
115108
assert len(result["hits"]) == 1
@@ -124,7 +117,7 @@ def test_pointer_swap_uses_uuid_file(self, indexed_tree):
124117

125118
def test_rebuild_when_manifest_changes(self, indexed_tree):
126119
patches = _index_patches(indexed_tree["cache_root"])
127-
with patches[0], patches[1], patches[2]:
120+
with patches[0]:
128121
assert build_search_index(indexed_tree["projects"], [], force=False) is False
129122
new_session = Path(indexed_tree["projects"]) / "demo-proj" / "session_beta.jsonl"
130123
_write_session(
@@ -161,7 +154,7 @@ def test_tool_result_stdout_indexed(self, tmp_path, monkeypatch):
161154
)
162155
monkeypatch.setenv("CLAUDE_CODE_CHAT_BROWSER_SEARCH_INDEX_DIR", str(cache_root))
163156
patches = _index_patches(cache_root)
164-
with patches[0], patches[1], patches[2]:
157+
with patches[0]:
165158
assert build_search_index(str(projects), [], force=True) is True
166159
hits = query_index_hits(sentinel, since_ms=None, max_results=5)
167160
assert hits["query_ok"] is True
@@ -191,7 +184,7 @@ def test_window_excludes_old_session(self, tmp_path, monkeypatch):
191184
)
192185
monkeypatch.setenv("CLAUDE_CODE_CHAT_BROWSER_SEARCH_INDEX_DIR", str(cache_root))
193186
patches = _index_patches(cache_root)
194-
with patches[0], patches[1], patches[2]:
187+
with patches[0]:
195188
build_search_index(str(projects), [], force=True)
196189
since_ms = resolve_search_since_ms(all_history=False)
197190
hits = query_index_hits("window-old-sentinel", since_ms=since_ms, max_results=5)
@@ -215,7 +208,7 @@ def test_all_history_includes_old_session(self, tmp_path, monkeypatch):
215208
)
216209
monkeypatch.setenv("CLAUDE_CODE_CHAT_BROWSER_SEARCH_INDEX_DIR", str(cache_root))
217210
patches = _index_patches(cache_root)
218-
with patches[0], patches[1], patches[2]:
211+
with patches[0]:
219212
build_search_index(str(projects), [], force=True)
220213
hits = query_index_hits("history-old-sentinel", since_ms=None, max_results=5)
221214
assert hits["query_ok"] is True
@@ -236,7 +229,7 @@ def test_undated_message_in_window(self, tmp_path, monkeypatch):
236229
)
237230
monkeypatch.setenv("CLAUDE_CODE_CHAT_BROWSER_SEARCH_INDEX_DIR", str(cache_root))
238231
patches = _index_patches(cache_root)
239-
with patches[0], patches[1], patches[2]:
232+
with patches[0]:
240233
build_search_index(str(projects), [], force=True)
241234
since_ms = resolve_search_since_ms(all_history=False)
242235
hits = query_index_hits("undated-window-sentinel", since_ms=since_ms, max_results=5)
@@ -258,9 +251,12 @@ def close(self) -> None:
258251
yield _Conn()
259252

260253
patches = _index_patches(indexed_tree["cache_root"])
261-
with patches[0], patches[1], patches[2], patch(
262-
"utils.search_index._index_db_conn",
263-
_broken_conn,
254+
with (
255+
patches[0],
256+
patch(
257+
"utils.search_index._index_db_conn",
258+
_broken_conn,
259+
),
264260
):
265261
result = query_index_hits(indexed_tree["term"], since_ms=None, max_results=5)
266262
assert result["query_ok"] is False
@@ -271,7 +267,7 @@ class TestBypassAndBackground:
271267
def test_no_search_index_env_disables_index(self, indexed_tree, monkeypatch):
272268
monkeypatch.setenv("CLAUDE_CODE_CHAT_BROWSER_NO_SEARCH_INDEX", "1")
273269
patches = _index_patches(indexed_tree["cache_root"])
274-
with patches[0], patches[1], patches[2]:
270+
with patches[0]:
275271
assert index_search_enabled() is False
276272
result = query_index_hits(indexed_tree["term"], since_ms=None, max_results=5)
277273
assert result["query_ok"] is False
@@ -284,9 +280,10 @@ def test_background_worker_starts_once(self, indexed_tree, monkeypatch):
284280
)
285281
reset_background_for_tests()
286282
patches = _index_patches(indexed_tree["cache_root"])
287-
with patches[0], patches[1], patches[2], patch(
288-
"utils.search_index.threading.Thread"
289-
) as mock_thread:
283+
with (
284+
patches[0],
285+
patch("utils.search_index.threading.Thread") as mock_thread,
286+
):
290287
start_search_index_background(indexed_tree["projects"], [])
291288
start_search_index_background(indexed_tree["projects"], [])
292289
assert mock_thread.call_count == 1

0 commit comments

Comments
 (0)