Skip to content

Commit e67fd5f

Browse files
feat: add SEARCH_INDEX_UNAVAILABLE when locked index fallback fails
1 parent a725bd2 commit e67fd5f

7 files changed

Lines changed: 126 additions & 23 deletions

File tree

api/search.py

Lines changed: 61 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
import logging
66
import os
77
from datetime import datetime, timezone
8-
from typing import Any
8+
from typing import Any, NamedTuple
99

1010
from flask import Blueprint, current_app, request
1111

@@ -36,6 +36,16 @@
3636
_MAX_SEARCH_SINCE_DAYS = 36_500
3737

3838

39+
class _IndexSearchOutcome(NamedTuple):
40+
hits: list[SearchHitDict] | None
41+
fts_exhausted: bool
42+
index_locked_without_hits: bool = False
43+
44+
45+
class _SearchIndexUnavailableError(Exception):
46+
"""Index locked with no partial hits and live-scan fallback failed."""
47+
48+
3949
def _parse_limit(raw: str | None, default: int = _DEFAULT_LIMIT) -> int:
4050
"""Parse a positive integer limit from a query string value."""
4151
if raw is None or raw.strip() == "":
@@ -145,9 +155,9 @@ def _search_via_index(
145155
*,
146156
since_ms: int | None,
147157
max_results: int,
148-
) -> tuple[list[SearchHitDict] | None, bool]:
158+
) -> _IndexSearchOutcome:
149159
if not index_search_enabled() or not index_is_usable(projects_dir, rules):
150-
return None, False
160+
return _IndexSearchOutcome(None, False)
151161

152162
rules_fp = rules_fingerprint(rules)
153163
results: list[SearchHitDict] = []
@@ -167,10 +177,10 @@ def _search_via_index(
167177
len(results),
168178
)
169179
if results:
170-
return _rank_search_hits(results)[:max_results], False
171-
return None, False
180+
return _IndexSearchOutcome(_rank_search_hits(results)[:max_results], False)
181+
return _IndexSearchOutcome(None, False, index_locked_without_hits=True)
172182
if not indexed["query_ok"]:
173-
return None, False
183+
return _IndexSearchOutcome(None, False)
174184
if indexed["sql_rows_fetched"] == 0:
175185
fts_exhausted = indexed["sql_exhausted"]
176186
break
@@ -201,7 +211,36 @@ def _search_via_index(
201211
if indexed["sql_exhausted"]:
202212
fts_exhausted = True
203213
break
204-
return _rank_search_hits(results)[:max_results], fts_exhausted
214+
return _IndexSearchOutcome(_rank_search_hits(results)[:max_results], fts_exhausted)
215+
216+
217+
def _live_scan_with_index_lock_fallback(
218+
base: str,
219+
rules: list[Any],
220+
query: str,
221+
query_lower: str,
222+
*,
223+
since_ms: int | None,
224+
max_results: int,
225+
index_locked_without_hits: bool,
226+
) -> list[SearchHitDict]:
227+
try:
228+
return _search_live_scan(
229+
base,
230+
rules,
231+
query,
232+
query_lower,
233+
since_ms=since_ms,
234+
max_results=max_results,
235+
)
236+
except Exception:
237+
if index_locked_without_hits:
238+
_logger.warning(
239+
"Search index locked; live-scan fallback failed",
240+
exc_info=True,
241+
)
242+
raise _SearchIndexUnavailableError from None
243+
raise
205244

206245

207246
def _resolve_search_results(
@@ -213,36 +252,38 @@ def _resolve_search_results(
213252
since_ms: int | None,
214253
max_results: int,
215254
) -> list[SearchHitDict]:
216-
indexed, _fts_exhausted = _search_via_index(
255+
outcome = _search_via_index(
217256
base,
218257
rules,
219258
query,
220259
query_lower,
221260
since_ms=since_ms,
222261
max_results=max_results,
223262
)
224-
if indexed is None:
225-
return _search_live_scan(
263+
if outcome.hits is None:
264+
return _live_scan_with_index_lock_fallback(
226265
base,
227266
rules,
228267
query,
229268
query_lower,
230269
since_ms=since_ms,
231270
max_results=max_results,
271+
index_locked_without_hits=outcome.index_locked_without_hits,
232272
)
233273

234-
if len(indexed) >= max_results:
235-
return indexed
274+
if len(outcome.hits) >= max_results:
275+
return outcome.hits
236276

237-
live = _search_live_scan(
277+
live = _live_scan_with_index_lock_fallback(
238278
base,
239279
rules,
240280
query,
241281
query_lower,
242282
since_ms=since_ms,
243283
max_results=max_results,
284+
index_locked_without_hits=False,
244285
)
245-
return _merge_search_hits(indexed, live, max_results=max_results)
286+
return _merge_search_hits(outcome.hits, live, max_results=max_results)
246287

247288

248289
def _search_live_scan(
@@ -359,6 +400,12 @@ def search() -> FlaskReturn:
359400
max_results=max_results,
360401
)
361402
)
403+
except _SearchIndexUnavailableError:
404+
return error_response(
405+
ErrorCode.SEARCH_INDEX_UNAVAILABLE,
406+
"Search index is temporarily unavailable",
407+
503,
408+
)
362409
except Exception:
363410
_logger.exception("Unexpected error during search")
364411
return error_response(

docs/api-reference.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@ Extra fields may appear for specific codes (for example `since` on invalid bulk-
6060
| `SEARCH_QUERY_TOO_LONG` | 400 | `GET /api/search` | Query param `q` exceeds 500 characters |
6161
| `SEARCH_INVALID_SINCE_DAYS` | 400 | `GET /api/search` | Query param `since_days` is not a positive integer |
6262
| `SEARCH_PROJECTS_UNAVAILABLE` | 503 | `GET /api/search` | Claude projects directory exists but is not readable |
63+
| `SEARCH_INDEX_UNAVAILABLE` | 503 | `GET /api/search` | FTS index locked during rebuild and live-scan fallback failed |
6364
| `INVALID_PATH` | 400 | Session, stats, export session | Path traversal or rejected URL segment |
6465
| `SESSION_NOT_FOUND` | 404 | Session, stats, export session | File missing on disk or session excluded by rules |
6566
| `INVALID_REQUEST_BODY` | 400 | `POST /api/export` | Body is not a JSON object |
@@ -319,6 +320,7 @@ By default, messages older than 30 days are excluded. Sessions without a parseab
319320
| 400 | `SEARCH_INVALID_LIMIT` | `limit` not a positive integer (e.g. `abc`, `0`, `1.5`) |
320321
| 400 | `SEARCH_INVALID_SINCE_DAYS` | `since_days` not a positive integer |
321322
| 503 | `SEARCH_PROJECTS_UNAVAILABLE` | Projects directory exists but is not readable |
323+
| 503 | `SEARCH_INDEX_UNAVAILABLE` | FTS index locked during rebuild and live-scan fallback failed |
322324
| 500 | `INTERNAL_ERROR` | Unexpected server failure |
323325

324326
```bash

models/error_codes.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ class ErrorCode(StrEnum):
1111
SEARCH_QUERY_TOO_LONG = "SEARCH_QUERY_TOO_LONG"
1212
SEARCH_INVALID_SINCE_DAYS = "SEARCH_INVALID_SINCE_DAYS"
1313
SEARCH_PROJECTS_UNAVAILABLE = "SEARCH_PROJECTS_UNAVAILABLE"
14+
SEARCH_INDEX_UNAVAILABLE = "SEARCH_INDEX_UNAVAILABLE"
1415
INVALID_PATH = "INVALID_PATH"
1516
SESSION_NOT_FOUND = "SESSION_NOT_FOUND"
1617
INVALID_REQUEST_BODY = "INVALID_REQUEST_BODY"

tests/test_error_codes.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,9 @@
22

33
from __future__ import annotations
44

5+
import json
6+
from types import SimpleNamespace
7+
58
import pytest
69

710
from api.error_codes import ErrorCode
@@ -73,3 +76,23 @@ def test_bulk_export_empty_includes_export_nothing_code(client_empty):
7376
resp = client_empty.post("/api/export", json={"since": "all"})
7477
assert resp.status_code == 422
7578
assert_error_response(resp, expected_code="EXPORT_NOTHING_TO_EXPORT")
79+
80+
81+
def test_search_index_unavailable_code(client_single, monkeypatch):
82+
monkeypatch.setattr(
83+
"api.search._search_via_index",
84+
lambda *_args, **_kwargs: SimpleNamespace(
85+
hits=None,
86+
fts_exhausted=False,
87+
index_locked_without_hits=True,
88+
),
89+
)
90+
monkeypatch.setattr(
91+
"api.search._search_live_scan",
92+
lambda *_args, **_kwargs: (_ for _ in ()).throw(RuntimeError("live scan failed")),
93+
)
94+
resp = client_single.get("/api/search?q=test")
95+
assert resp.status_code == 503
96+
body_text = json.dumps(resp.get_json())
97+
assert_error_response(resp, expected_code=ErrorCode.SEARCH_INDEX_UNAVAILABLE)
98+
assert "live scan failed" not in body_text

tests/test_error_propagation.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ class names from a defensive blocklist.
3333
from flask import Flask
3434

3535
from api.projects import projects_bp
36+
from api.search import _IndexSearchOutcome
3637
from api.sessions import sessions_bp
3738

3839
# Defensive blocklist — any of these substrings appearing in a response body
@@ -212,7 +213,10 @@ def test_search_internal_error_does_not_leak(client_single, monkeypatch):
212213
def _boom(*_args, **_kwargs):
213214
raise RuntimeError("internal_secret_search_token")
214215

215-
monkeypatch.setattr("api.search._search_via_index", lambda *_a, **_kw: (None, False))
216+
monkeypatch.setattr(
217+
"api.search._search_via_index",
218+
lambda *_a, **_kw: _IndexSearchOutcome(None, False),
219+
)
216220
monkeypatch.setattr("api.search._search_live_scan", _boom)
217221

218222
resp = client_single.get("/api/search?q=Hello&all_history=1")

tests/test_search.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,31 @@ def test_index_lock_falls_back_to_live_scan(tmp_path, monkeypatch):
136136
assert len(resp.get_json()) >= 1
137137

138138

139+
def test_index_lock_returns_unavailable_when_live_scan_fails(tmp_path, monkeypatch):
140+
recent_ts = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ")
141+
client = _seed_indexed_client(tmp_path, monkeypatch, timestamp=recent_ts)
142+
with (
143+
patch(
144+
"api.search.query_index_hits",
145+
return_value={
146+
"hits": [],
147+
"query_ok": True,
148+
"sql_rows_fetched": 0,
149+
"sql_exhausted": True,
150+
"index_locked": True,
151+
},
152+
),
153+
patch(
154+
"api.search._search_live_scan",
155+
side_effect=RuntimeError("live scan failed"),
156+
),
157+
):
158+
resp = client.get("/api/search?q=Hello")
159+
assert resp.status_code == 503
160+
assert_error_response(resp, expected_code="SEARCH_INDEX_UNAVAILABLE")
161+
assert "live scan failed" not in json.dumps(resp.get_json())
162+
163+
139164
def _index_patches(cache_root: Path):
140165
return (patch("utils.search_index.cache_dir", return_value=cache_root),)
141166

tests/test_search_index.py

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -315,17 +315,18 @@ def test_phrase_filter_paginates_past_decoy_token_matches(self, tmp_path, monkey
315315
patches = _index_patches(cache_root)
316316
with patches[0]:
317317
build_search_index(str(projects), [], force=True)
318-
results, _fts_exhausted = _search_via_index(
318+
outcome = _search_via_index(
319319
str(projects),
320320
[],
321321
phrase,
322322
phrase.lower(),
323323
since_ms=None,
324324
max_results=1,
325325
)
326-
assert results is not None
327-
assert len(results) == 1
328-
assert phrase in results[0]["snippet"] or "phrase target" in results[0]["snippet"]
326+
assert outcome.hits is not None
327+
assert len(outcome.hits) == 1
328+
snippet = outcome.hits[0]["snippet"]
329+
assert phrase in snippet or "phrase target" in snippet
329330

330331
def test_fts_failure_returns_query_not_ok(self, indexed_tree):
331332
@contextmanager
@@ -453,17 +454,17 @@ def test_index_search_fills_limit_after_excluded_hits(self, tmp_path, monkeypatc
453454
patches = _index_patches(cache_root)
454455
with patches[0]:
455456
build_search_index(str(projects), rules, force=True)
456-
results, _fts_exhausted = _search_via_index(
457+
outcome = _search_via_index(
457458
str(projects),
458459
rules,
459460
term,
460461
term.lower(),
461462
since_ms=None,
462463
max_results=50,
463464
)
464-
assert results is not None
465-
assert len(results) == 50
466-
assert all(hit["project"] == "good-proj" for hit in results)
465+
assert outcome.hits is not None
466+
assert len(outcome.hits) == 50
467+
assert all(hit["project"] == "good-proj" for hit in outcome.hits)
467468

468469
def test_system_content_index_and_live_scan_parity(self, tmp_path, monkeypatch):
469470
cache_root = tmp_path / "cache"

0 commit comments

Comments
 (0)