Skip to content

Commit 793b6f2

Browse files
Speed up workspace listing and search; add FTS index and fix search d… (#113)
* Speed up workspace listing and search; add FTS index and fix search deep links * address PR feedback * Fix review follow-ups for search window order and shared tab parsing * Address feedback from brad * Track indexed composers only after INSERT; tighten parity test
1 parent 2f728d8 commit 793b6f2

25 files changed

Lines changed: 2525 additions & 474 deletions

api/search.py

Lines changed: 52 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
"""
22
API route for search — mirrors src/app/api/search/route.ts
3-
GET /api/search?q=...&type=all|chat|composer
3+
GET /api/search?q=...&type=all|chat|composer&all_history=1
44
"""
55

66
import logging
@@ -12,7 +12,9 @@
1212

1313
from models import ParseWarningCollector, SearchResult
1414
from services.search import (
15+
DEFAULT_SEARCH_WINDOW_DAYS,
1516
rank_results,
17+
resolve_search_since_ms,
1618
search_cli_sessions,
1719
search_global_storage,
1820
search_legacy_workspaces,
@@ -22,6 +24,20 @@
2224
bp = Blueprint("search", __name__)
2325
_logger = logging.getLogger(__name__)
2426

27+
_MAX_SEARCH_SINCE_DAYS = 36_500 # ~100 years; avoids timedelta overflow on bad input
28+
29+
30+
def _parse_since_days_param(raw: str | None) -> int | None:
31+
if raw is None or not str(raw).strip():
32+
return None
33+
try:
34+
days = int(raw)
35+
except ValueError:
36+
return None
37+
if days <= 0 or days > _MAX_SEARCH_SINCE_DAYS:
38+
return None
39+
return days
40+
2541

2642
@bp.route("/api/search")
2743
def search() -> tuple[Response, int] | Response:
@@ -39,6 +55,11 @@ def search() -> tuple[Response, int] | Response:
3955
query = request.args.get("q", "").strip()
4056
search_type = request.args.get("type", "all")
4157
rules = current_app.config.get("EXCLUSION_RULES") or []
58+
all_history = request.args.get("all_history") in ("1", "true")
59+
since_ms = resolve_search_since_ms(
60+
all_history=all_history,
61+
since_days=_parse_since_days_param(request.args.get("since_days")),
62+
)
4263

4364
if not query:
4465
return json_response({"error": "No search query provided"}, 400)
@@ -50,20 +71,46 @@ def search() -> tuple[Response, int] | Response:
5071
if search_type != "chat":
5172
results.extend(
5273
search_global_storage(
53-
workspace_path, query, query_lower, rules, parse_warnings
74+
workspace_path,
75+
query,
76+
query_lower,
77+
rules,
78+
parse_warnings,
79+
since_ms=since_ms,
5480
)
5581
)
5682
results.extend(
57-
search_legacy_workspaces(workspace_path, query, query_lower, search_type, rules)
83+
search_legacy_workspaces(
84+
workspace_path,
85+
query,
86+
query_lower,
87+
search_type,
88+
rules,
89+
since_ms=since_ms,
90+
)
5891
)
5992
if search_type == "all":
6093
results.extend(
6194
search_cli_sessions(
62-
get_cli_chats_path(), query, query_lower, rules, parse_warnings
95+
get_cli_chats_path(),
96+
query,
97+
query_lower,
98+
rules,
99+
parse_warnings,
100+
since_ms=since_ms,
63101
)
64102
)
65103

66-
payload: dict[str, Any] = {"results": rank_results(results)}
104+
payload: dict[str, Any] = {
105+
"results": rank_results(results),
106+
"allHistory": since_ms is None,
107+
"searchWindowDays": (
108+
None if since_ms is None else (
109+
_parse_since_days_param(request.args.get("since_days"))
110+
or DEFAULT_SEARCH_WINDOW_DAYS
111+
)
112+
),
113+
}
67114
return json_response(parse_warnings.attach_to(payload))
68115

69116
except Exception:

app.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,17 @@ def inject_year() -> dict[str, int]:
6969
app.register_blueprint(pdf_bp)
7070
app.register_blueprint(config_bp)
7171

72+
try:
73+
from services.search_index import start_search_index_background
74+
from utils.workspace_path import resolve_workspace_path
75+
76+
start_search_index_background(
77+
resolve_workspace_path(),
78+
app.config["EXCLUSION_RULES"],
79+
)
80+
except Exception:
81+
logging.getLogger(__name__).exception("Failed to start search index background worker")
82+
7283
# ---------- Page routes ----------
7384

7485
@app.route("/")

scripts/profile_search.py

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
"""One-off search profiler — run: python scripts/profile_search.py [query]"""
2+
from __future__ import annotations
3+
4+
import os
5+
import sys
6+
import time
7+
8+
REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
9+
if REPO_ROOT not in sys.path:
10+
sys.path.insert(0, REPO_ROOT)
11+
12+
from models import ParseWarningCollector
13+
from services.search import search_global_storage
14+
from services.workspace_db import (
15+
COMPOSER_ROWS_WITH_HEADERS_SQL,
16+
build_composer_id_to_workspace_id_cached,
17+
collect_workspace_entries,
18+
open_global_db,
19+
)
20+
from services.search import _index_bubble_texts_matching_query, _sql_like_substring
21+
from utils.workspace_path import resolve_workspace_path
22+
23+
24+
def main() -> None:
25+
query = sys.argv[1] if len(sys.argv) > 1 else "export"
26+
q = query.lower()
27+
wp = resolve_workspace_path()
28+
entries = collect_workspace_entries(wp)
29+
30+
t0 = time.perf_counter()
31+
build_composer_id_to_workspace_id_cached(wp, entries, [])
32+
print(f"composer_map: {time.perf_counter() - t0:.2f}s")
33+
34+
with open_global_db(wp) as (conn, _):
35+
if conn is None:
36+
print("no global db")
37+
return
38+
t0 = time.perf_counter()
39+
rows = conn.execute(COMPOSER_ROWS_WITH_HEADERS_SQL).fetchall()
40+
print(f"composer_rows {len(rows)}: {time.perf_counter() - t0:.2f}s")
41+
42+
t0 = time.perf_counter()
43+
bubble_count = conn.execute(
44+
"SELECT count(*) FROM cursorDiskKV WHERE key LIKE 'bubbleId:%'"
45+
).fetchone()[0]
46+
print(f"bubble_count {bubble_count}: {time.perf_counter() - t0:.2f}s")
47+
48+
t0 = time.perf_counter()
49+
idx = _index_bubble_texts_matching_query(conn, q)
50+
print(f"bubble_index {len(idx)} composers: {time.perf_counter() - t0:.2f}s")
51+
52+
pattern = _sql_like_substring(q)
53+
t0 = time.perf_counter()
54+
raw_hits = sum(
55+
1 for row in rows
56+
if q in (row["value"] or "").lower()
57+
)
58+
print(f"composer_raw_hits {raw_hits}: {time.perf_counter() - t0:.2f}s")
59+
60+
t0 = time.perf_counter()
61+
results = search_global_storage(wp, query, q, [], ParseWarningCollector())
62+
print(f"search_global_storage {len(results)} hits: {time.perf_counter() - t0:.2f}s")
63+
64+
65+
if __name__ == "__main__":
66+
main()
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
"""Break down where search time goes with vs without window."""
2+
from __future__ import annotations
3+
4+
import json
5+
import os
6+
import sys
7+
import time
8+
9+
REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
10+
if REPO_ROOT not in sys.path:
11+
sys.path.insert(0, REPO_ROOT)
12+
13+
from services.search import (
14+
_composer_dict_timestamp_ms,
15+
_composer_row_raw_text,
16+
_index_bubble_texts_matching_query,
17+
_timestamp_in_search_window,
18+
resolve_search_since_ms,
19+
)
20+
from services.workspace_db import (
21+
COMPOSER_ROWS_WITH_HEADERS_SQL,
22+
build_composer_id_to_workspace_id_cached,
23+
collect_workspace_entries,
24+
open_global_db,
25+
)
26+
from utils.workspace_path import resolve_workspace_path
27+
28+
29+
def profile_window(since_ms: int | None, label: str) -> None:
30+
query = "export"
31+
q = query.lower()
32+
wp = resolve_workspace_path()
33+
entries = collect_workspace_entries(wp)
34+
35+
t0 = time.perf_counter()
36+
build_composer_id_to_workspace_id_cached(wp, entries, [])
37+
print(f" composer_map: {time.perf_counter() - t0:.2f}s")
38+
39+
with open_global_db(wp) as (conn, _):
40+
if conn is None:
41+
return
42+
t0 = time.perf_counter()
43+
composer_rows = conn.execute(COMPOSER_ROWS_WITH_HEADERS_SQL).fetchall()
44+
print(f" load_composer_rows ({len(composer_rows)}): {time.perf_counter() - t0:.2f}s")
45+
46+
window_ids = None
47+
if since_ms is not None:
48+
t0 = time.perf_counter()
49+
window_ids = set()
50+
in_window_rows = []
51+
for row in composer_rows:
52+
composer_id = row["key"].split(":")[1]
53+
raw_text = _composer_row_raw_text(row)
54+
try:
55+
cd_probe = json.loads(raw_text)
56+
except Exception:
57+
continue
58+
if not isinstance(cd_probe, dict):
59+
continue
60+
if not _timestamp_in_search_window(
61+
_composer_dict_timestamp_ms(cd_probe), since_ms
62+
):
63+
continue
64+
window_ids.add(composer_id)
65+
in_window_rows.append(row)
66+
composer_rows = in_window_rows
67+
print(
68+
f" window_filter ({len(window_ids)} composers): "
69+
f"{time.perf_counter() - t0:.2f}s"
70+
)
71+
72+
t0 = time.perf_counter()
73+
idx = _index_bubble_texts_matching_query(conn, q, composer_ids=window_ids)
74+
mode = (
75+
"full_scan_python_filter"
76+
if window_ids is not None and len(window_ids) <= 500
77+
else "full_scan"
78+
)
79+
print(
80+
f" bubble_index ({len(idx)} hits, {mode}): "
81+
f"{time.perf_counter() - t0:.2f}s"
82+
)
83+
84+
t0 = time.perf_counter()
85+
hits = 0
86+
for row in composer_rows:
87+
raw = _composer_row_raw_text(row)
88+
cid = row["key"].split(":")[1]
89+
if q in raw.lower() or cid in idx:
90+
hits += 1
91+
print(f" composer_match_loop ({hits} candidates): {time.perf_counter() - t0:.2f}s")
92+
93+
94+
def main() -> None:
95+
since = resolve_search_since_ms(all_history=False)
96+
print("=== 30-day window ===")
97+
profile_window(since, "30d")
98+
print("=== all history ===")
99+
profile_window(None, "all")
100+
101+
102+
if __name__ == "__main__":
103+
main()

scripts/profile_search_window.py

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
"""Profile search with 30-day window vs all history."""
2+
from __future__ import annotations
3+
4+
import json
5+
import os
6+
import sys
7+
import time
8+
from datetime import datetime, timezone
9+
10+
REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
11+
if REPO_ROOT not in sys.path:
12+
sys.path.insert(0, REPO_ROOT)
13+
14+
from models import ParseWarningCollector
15+
from services.search import (
16+
_composer_dict_timestamp_ms,
17+
_timestamp_in_search_window,
18+
resolve_search_since_ms,
19+
search_cli_sessions,
20+
search_global_storage,
21+
search_legacy_workspaces,
22+
)
23+
from services.workspace_db import COMPOSER_ROWS_WITH_HEADERS_SQL, open_global_db
24+
from utils.workspace_path import get_cli_chats_path, resolve_workspace_path
25+
26+
27+
def main() -> None:
28+
query = sys.argv[1] if len(sys.argv) > 1 else "export"
29+
q = query.lower()
30+
wp = resolve_workspace_path()
31+
since = resolve_search_since_ms(all_history=False)
32+
assert since is not None
33+
print(
34+
f"since_ms={since} "
35+
f"({datetime.fromtimestamp(since / 1000, tz=timezone.utc).isoformat()})"
36+
)
37+
38+
with open_global_db(wp) as (conn, _):
39+
if conn is None:
40+
print("no global db")
41+
return
42+
rows = conn.execute(COMPOSER_ROWS_WITH_HEADERS_SQL).fetchall()
43+
total = len(rows)
44+
in_window = 0
45+
unknown_ts = 0
46+
for row in rows:
47+
try:
48+
cd = json.loads(row["value"])
49+
except Exception:
50+
continue
51+
ts = _composer_dict_timestamp_ms(cd)
52+
if ts <= 0:
53+
unknown_ts += 1
54+
if _timestamp_in_search_window(ts, since):
55+
in_window += 1
56+
bubble_count = conn.execute(
57+
"SELECT count(*) FROM cursorDiskKV WHERE key LIKE 'bubbleId:%'"
58+
).fetchone()[0]
59+
60+
print(f"composers total={total} in_30d_window={in_window} unknown_ts={unknown_ts}")
61+
print(f"bubble_rows={bubble_count}")
62+
print(f"below_bubble_threshold={in_window <= 500}")
63+
64+
pw = ParseWarningCollector()
65+
for label, since_ms in [("30d", since), ("all", None)]:
66+
t0 = time.perf_counter()
67+
r1 = search_global_storage(wp, query, q, [], pw, since_ms=since_ms)
68+
t1 = time.perf_counter() - t0
69+
t0 = time.perf_counter()
70+
r2 = search_legacy_workspaces(wp, query, q, "all", [], since_ms=since_ms)
71+
t2 = time.perf_counter() - t0
72+
t0 = time.perf_counter()
73+
r3 = search_cli_sessions(get_cli_chats_path(), query, q, [], pw, since_ms=since_ms)
74+
t3 = time.perf_counter() - t0
75+
total_hits = len(r1) + len(r2) + len(r3)
76+
total_s = t1 + t2 + t3
77+
print(
78+
f"{label}: global={len(r1)} ({t1:.2f}s) "
79+
f"legacy={len(r2)} ({t2:.2f}s) cli={len(r3)} ({t3:.2f}s) "
80+
f"total={total_hits} ({total_s:.2f}s)"
81+
)
82+
83+
84+
if __name__ == "__main__":
85+
main()

0 commit comments

Comments
 (0)