Optimize sorting, index loading, and caches
Browse files- app.py +288 -154
- prepare_fulltext_index.py +8 -10
- static/sw.js +2 -19
app.py
CHANGED
|
@@ -13,7 +13,7 @@ import threading
|
|
| 13 |
import time
|
| 14 |
import unicodedata
|
| 15 |
from functools import lru_cache
|
| 16 |
-
from contextlib import asynccontextmanager
|
| 17 |
from pathlib import Path
|
| 18 |
from typing import Literal, Optional
|
| 19 |
from urllib.parse import parse_qs, quote, unquote
|
|
@@ -32,6 +32,8 @@ FULLTEXT_DIR = BASE_DIR / "data/fulltext"
|
|
| 32 |
INDEX_BUILD_STATUS_PATH = FULLTEXT_DIR / "build-status.json"
|
| 33 |
BUCKET_INDEX_STATUS_PATH = FULLTEXT_DIR / "bucket-index-status.json"
|
| 34 |
records: list[dict] = []
|
|
|
|
|
|
|
| 35 |
record_map: dict[str, dict] = {}
|
| 36 |
record_map_index: dict[str, int] = {}
|
| 37 |
sources: list[dict] = []
|
|
@@ -47,8 +49,12 @@ TOKENIZER_VERSION = "cjk-bigram-boundary-fts5-v6-snippet-anchors"
|
|
| 47 |
LITERAL_PREFIX = "\0literal:"
|
| 48 |
API_CACHE_TTL_SECONDS = 120
|
| 49 |
SEARCH_CACHE_TTL_SECONDS = 300
|
|
|
|
|
|
|
| 50 |
api_response_cache: dict[tuple, tuple[float, object]] = {}
|
| 51 |
api_response_cache_lock = threading.Lock()
|
|
|
|
|
|
|
| 52 |
ZIP_TOKEN_TTL_SECONDS = 600
|
| 53 |
ZIP_TOKEN_MAX_ENTRIES = 32
|
| 54 |
zip_download_tokens: dict[str, tuple[float, list[str]]] = {}
|
|
@@ -110,6 +116,7 @@ class FulltextDatabases:
|
|
| 110 |
self.directory = directory
|
| 111 |
self.in_memory = in_memory
|
| 112 |
self.lock = threading.RLock()
|
|
|
|
| 113 |
self.connections = {}
|
| 114 |
self.has_doc_counts = {}
|
| 115 |
self.tokenizer_versions = {}
|
|
@@ -118,27 +125,45 @@ class FulltextDatabases:
|
|
| 118 |
self.document_counts = {}
|
| 119 |
self.fts_counts = {}
|
| 120 |
self.file_signatures = {}
|
| 121 |
-
self.
|
|
|
|
|
|
|
| 122 |
self.generation = 0
|
| 123 |
if refresh:
|
| 124 |
self.refresh()
|
| 125 |
|
| 126 |
def refresh(self) -> None:
|
| 127 |
-
with self.
|
| 128 |
for path in self.directory.glob("*.sqlite3"):
|
| 129 |
try:
|
| 130 |
stat = path.stat()
|
| 131 |
signature = (stat.st_ino, stat.st_size, stat.st_mtime_ns)
|
| 132 |
except OSError:
|
| 133 |
continue
|
| 134 |
-
|
| 135 |
-
|
|
|
|
| 136 |
try:
|
| 137 |
-
self.
|
| 138 |
except (OSError, sqlite3.Error) as exc:
|
| 139 |
print(f"fulltext_open_failed={path.name}:{exc}")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 140 |
|
| 141 |
-
def
|
| 142 |
uri = f"file:{path.resolve()}?mode=ro&immutable=1"
|
| 143 |
source_connection = sqlite3.connect(uri, uri=True, check_same_thread=False)
|
| 144 |
connection = source_connection
|
|
@@ -165,48 +190,96 @@ class FulltextDatabases:
|
|
| 165 |
except Exception:
|
| 166 |
if connection is not source_connection:
|
| 167 |
connection.close()
|
| 168 |
-
|
| 169 |
raise
|
| 170 |
finally:
|
| 171 |
if source_connection is not connection:
|
| 172 |
source_connection.close()
|
| 173 |
-
|
| 174 |
-
|
| 175 |
-
|
| 176 |
-
|
| 177 |
-
|
| 178 |
-
|
| 179 |
-
|
| 180 |
-
|
| 181 |
-
|
| 182 |
-
|
| 183 |
-
|
| 184 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 185 |
self.generation += 1
|
| 186 |
if old_connection is not None:
|
| 187 |
-
|
| 188 |
-
|
| 189 |
-
|
| 190 |
-
|
| 191 |
|
| 192 |
-
|
|
|
|
| 193 |
with self.lock:
|
| 194 |
-
|
| 195 |
-
|
| 196 |
-
|
| 197 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 198 |
|
| 199 |
def search_source(self, source: str, query: str, exact: bool = False) -> set[int]:
|
| 200 |
with self.lock:
|
| 201 |
-
|
| 202 |
-
|
| 203 |
-
|
| 204 |
-
|
| 205 |
-
if
|
| 206 |
return set()
|
| 207 |
-
|
| 208 |
-
|
| 209 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 210 |
if exact and has_content_fts:
|
| 211 |
normalized_query = normalize_text(query)
|
| 212 |
try:
|
|
@@ -238,7 +311,7 @@ class FulltextDatabases:
|
|
| 238 |
"SELECT rowid FROM content_fts WHERE instr(content, ?) > 0", (normalized_query,)
|
| 239 |
)
|
| 240 |
return {int(row[0]) for row in rows}
|
| 241 |
-
candidates = self._search_postings(connection,
|
| 242 |
return candidates
|
| 243 |
except sqlite3.OperationalError:
|
| 244 |
pass
|
|
@@ -277,12 +350,12 @@ class FulltextDatabases:
|
|
| 277 |
result = self.verify_wildcard_content(connection, result, query)
|
| 278 |
return result
|
| 279 |
|
| 280 |
-
def _search_postings(self, connection: sqlite3.Connection,
|
| 281 |
if not tokens:
|
| 282 |
return set()
|
| 283 |
matched = None
|
| 284 |
token_hashes = [hashlib.sha256(token.encode("utf-8")).digest()[:16] for token in tokens]
|
| 285 |
-
if
|
| 286 |
placeholders = ",".join("?" for _ in token_hashes)
|
| 287 |
rows = connection.execute(
|
| 288 |
f"SELECT token_hash, docs, doc_count FROM postings WHERE token_hash IN ({placeholders})",
|
|
@@ -359,34 +432,38 @@ class FulltextDatabases:
|
|
| 359 |
return matched
|
| 360 |
|
| 361 |
def can_trust_exact_content(self, source: str, query: str) -> bool:
|
| 362 |
-
|
| 363 |
-
|
| 364 |
-
|
| 365 |
-
|
| 366 |
-
|
| 367 |
-
|
|
|
|
| 368 |
|
| 369 |
def summaries(self, doc_ids: list[str]) -> dict[str, str]:
|
| 370 |
-
self.refresh()
|
| 371 |
grouped = {}
|
| 372 |
for doc_id in doc_ids:
|
| 373 |
grouped.setdefault(doc_id.split(":", 1)[0], []).append(doc_id)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 374 |
summaries = {}
|
| 375 |
for source, source_doc_ids in grouped.items():
|
| 376 |
-
|
| 377 |
-
|
| 378 |
-
|
| 379 |
-
|
| 380 |
-
|
| 381 |
-
|
| 382 |
-
|
| 383 |
-
|
| 384 |
-
|
| 385 |
-
|
|
|
|
| 386 |
return summaries
|
| 387 |
|
| 388 |
def matched_snippets(self, doc_ids: list[str], query: str, exact: bool = False, timings=None) -> dict[str, dict]:
|
| 389 |
-
self.refresh()
|
| 390 |
normalized_query = normalize_text(query.strip())
|
| 391 |
# A fixed wildcard fragment can occur long before the actual match.
|
| 392 |
# Let the original-text snippet endpoint locate the verified span.
|
|
@@ -403,88 +480,100 @@ class FulltextDatabases:
|
|
| 403 |
source, _, number = doc_id.partition(":")
|
| 404 |
if number.isdigit():
|
| 405 |
grouped.setdefault(source, []).append((doc_id, int(number)))
|
|
|
|
|
|
|
|
|
|
|
|
|
| 406 |
snippets = {}
|
| 407 |
for source, source_docs in grouped.items():
|
| 408 |
-
|
| 409 |
-
|
| 410 |
-
|
| 411 |
-
|
| 412 |
-
|
| 413 |
-
|
| 414 |
-
|
| 415 |
-
|
| 416 |
-
|
| 417 |
-
|
| 418 |
-
|
| 419 |
-
|
| 420 |
-
|
| 421 |
-
|
| 422 |
-
|
| 423 |
-
|
| 424 |
-
|
| 425 |
-
|
| 426 |
-
|
| 427 |
-
FROM content_fts JOIN snippet_anchors ON snippet_anchors.doc_number = content_fts.rowid
|
| 428 |
-
WHERE snippet_anchors.token_hash = ? AND content_fts.rowid IN ({placeholders})
|
| 429 |
-
""",
|
| 430 |
-
[needle, token_hash, *batch],
|
| 431 |
-
).fetchall()
|
| 432 |
-
if timings is not None:
|
| 433 |
-
timings["snippet_anchor_sql"] = timings.get("snippet_anchor_sql", 0) + (time.perf_counter() - anchor_started) * 1000
|
| 434 |
-
for number, char_offset, content_length, text in rows:
|
| 435 |
-
payload_started = time.perf_counter()
|
| 436 |
-
payload = build_normalized_snippet_payload(text or "", query)
|
| 437 |
-
if timings is not None:
|
| 438 |
-
timings["snippet_payload"] = timings.get("snippet_payload", 0) + (time.perf_counter() - payload_started) * 1000
|
| 439 |
-
if not payload["highlights"]:
|
| 440 |
-
continue
|
| 441 |
-
extracted_start = max(int(char_offset) - 219, 1)
|
| 442 |
-
if extracted_start > 1 and not payload["snippet"].startswith("..."):
|
| 443 |
-
payload["snippet"] = "..." + payload["snippet"]
|
| 444 |
-
if extracted_start + len(text or "") <= int(content_length or 0) and not payload["snippet"].endswith("..."):
|
| 445 |
-
payload["snippet"] += "..."
|
| 446 |
-
snippets[doc_id_by_number[int(number)]] = payload
|
| 447 |
-
anchored_numbers.add(int(number))
|
| 448 |
-
except sqlite3.OperationalError:
|
| 449 |
-
break
|
| 450 |
-
numbers = [number for number in numbers if number not in anchored_numbers]
|
| 451 |
for offset in range(0, len(numbers), 500):
|
| 452 |
batch = numbers[offset:offset + 500]
|
| 453 |
placeholders = ",".join("?" for _ in batch)
|
| 454 |
try:
|
| 455 |
-
|
| 456 |
rows = connection.execute(
|
| 457 |
f"""
|
| 458 |
-
SELECT rowid,
|
| 459 |
-
substr(content, max(
|
| 460 |
-
FROM content_fts
|
| 461 |
-
WHERE rowid IN ({placeholders})
|
| 462 |
""",
|
| 463 |
-
[needle,
|
| 464 |
).fetchall()
|
| 465 |
if timings is not None:
|
| 466 |
-
timings["
|
| 467 |
-
for number,
|
| 468 |
payload_started = time.perf_counter()
|
| 469 |
-
|
| 470 |
-
if exact and not has_wildcard_query(query):
|
| 471 |
-
payload = build_normalized_exact_snippet_payload(text or "", query)
|
| 472 |
-
if not payload["highlights"]:
|
| 473 |
-
payload = build_snippet_payload(text or "", query, exact=True)
|
| 474 |
-
else:
|
| 475 |
-
payload = build_snippet_payload(text or "", query, exact=exact)
|
| 476 |
-
if not payload["highlights"]:
|
| 477 |
-
payload = build_snippet_payload(text or "", needle, exact=True)
|
| 478 |
if timings is not None:
|
| 479 |
timings["snippet_payload"] = timings.get("snippet_payload", 0) + (time.perf_counter() - payload_started) * 1000
|
| 480 |
-
if
|
|
|
|
|
|
|
|
|
|
| 481 |
payload["snippet"] = "..." + payload["snippet"]
|
| 482 |
-
if
|
| 483 |
payload["snippet"] += "..."
|
| 484 |
snippets[doc_id_by_number[int(number)]] = payload
|
|
|
|
| 485 |
except sqlite3.OperationalError:
|
| 486 |
-
|
| 487 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 488 |
fulltext_databases: Optional[FulltextDatabases] = None
|
| 489 |
|
| 490 |
def tokenize(text: str) -> list[str]:
|
|
@@ -616,7 +705,7 @@ def load_json_gz(path: Path):
|
|
| 616 |
return json.loads(gzip.decompress(path.read_bytes()).decode("utf-8"))
|
| 617 |
|
| 618 |
def build_indexes() -> None:
|
| 619 |
-
global word_index, source_records_map, extension_counts, source_extension_counts
|
| 620 |
word_index = {}
|
| 621 |
source_records_map = {}
|
| 622 |
extension_counts = {}
|
|
@@ -637,6 +726,18 @@ def build_indexes() -> None:
|
|
| 637 |
rec["_rank_path_key"] = rec["display_rel_path"].lower()
|
| 638 |
for token in tokens:
|
| 639 |
word_index.setdefault(token, set()).add(idx)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 640 |
|
| 641 |
def load_data() -> None:
|
| 642 |
global records, record_map, record_map_index, sources, source_counts, folder_tree_data, folder_browser_data
|
|
@@ -674,6 +775,22 @@ def relevance_page(indices, query_tokens: list[str], search_paths: bool, start:
|
|
| 674 |
)
|
| 675 |
return ranked[start:limit]
|
| 676 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 677 |
def apply_filters(indices: list[int], sources_filter=None, folders=None, min_size=None, max_size=None):
|
| 678 |
result = []
|
| 679 |
for idx in indices:
|
|
@@ -826,16 +943,19 @@ def search(q="", sources_filter=None, folders=None, min_size=None, max_size=None
|
|
| 826 |
paged_indices = relevance_page(filtered, tokens, search_paths, start, page_size)
|
| 827 |
if timings is not None:
|
| 828 |
timings["rank"] = (time.perf_counter() - rank_started) * 1000
|
| 829 |
-
sort_started = time.perf_counter()
|
| 830 |
-
if sort == "name":
|
| 831 |
-
filtered.sort(key=lambda idx: (records[idx]["display_rel_path"].lower(), records[idx]["doc_id"]))
|
| 832 |
-
elif sort == "size":
|
| 833 |
-
filtered.sort(key=lambda idx: (-records[idx].get("size", 0), records[idx]["display_rel_path"].lower(), records[idx]["doc_id"]))
|
| 834 |
-
if timings is not None and sort in {"name", "size"}:
|
| 835 |
-
timings["sort"] = (time.perf_counter() - sort_started) * 1000
|
| 836 |
total = len(filtered)
|
| 837 |
if q and sort == "relevance":
|
| 838 |
selected = paged_indices
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 839 |
else:
|
| 840 |
selected = filtered[start:start + page_size]
|
| 841 |
result_items = [trim_record(records[idx]) for idx in selected]
|
|
@@ -880,18 +1000,15 @@ def fulltext_search(q="", sources_filter=None, folders=None, min_size=None, max_
|
|
| 880 |
timings["rank"] = (time.perf_counter() - rank_started) * 1000
|
| 881 |
elif sort == "name":
|
| 882 |
sort_started = time.perf_counter()
|
| 883 |
-
|
| 884 |
if timings is not None:
|
| 885 |
timings["sort"] = (time.perf_counter() - sort_started) * 1000
|
| 886 |
elif sort == "size":
|
| 887 |
sort_started = time.perf_counter()
|
| 888 |
-
|
| 889 |
if timings is not None:
|
| 890 |
timings["sort"] = (time.perf_counter() - sort_started) * 1000
|
| 891 |
total = len(filtered)
|
| 892 |
-
start = (page - 1) * page_size
|
| 893 |
-
if sort != "relevance":
|
| 894 |
-
selected = filtered[start:start + page_size]
|
| 895 |
summaries_started = time.perf_counter()
|
| 896 |
result_items = add_summaries(
|
| 897 |
[trim_record(records[idx]) for idx in selected],
|
|
@@ -917,12 +1034,11 @@ def get_folder_contents(source_slug: str, path: str) -> dict:
|
|
| 917 |
return entry
|
| 918 |
return {"folders": [], "files": [], "current_path": path}
|
| 919 |
|
| 920 |
-
def
|
| 921 |
cache_started = time.perf_counter()
|
| 922 |
now = time.monotonic()
|
| 923 |
-
with
|
| 924 |
-
cached =
|
| 925 |
-
ttl = SEARCH_CACHE_TTL_SECONDS if key and key[0] == "search" else API_CACHE_TTL_SECONDS
|
| 926 |
if cached and now - cached[0] < ttl:
|
| 927 |
if timings is not None:
|
| 928 |
timings["cache"] = "hit"
|
|
@@ -932,24 +1048,38 @@ def cached_payload(key: tuple, builder, timings=None):
|
|
| 932 |
timings["cache"] = "miss"
|
| 933 |
timings["cache_lookup"] = (time.perf_counter() - cache_started) * 1000
|
| 934 |
value = builder()
|
| 935 |
-
|
| 936 |
-
|
| 937 |
-
|
| 938 |
-
|
| 939 |
-
|
|
|
|
| 940 |
return value
|
| 941 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 942 |
def ready_fulltext_sources() -> set[str]:
|
| 943 |
if fulltext_databases is None:
|
| 944 |
return set()
|
| 945 |
-
|
| 946 |
-
|
| 947 |
-
|
| 948 |
-
|
| 949 |
-
|
| 950 |
-
|
| 951 |
-
|
| 952 |
-
|
|
|
|
| 953 |
@asynccontextmanager
|
| 954 |
|
| 955 |
async def lifespan(app: FastAPI):
|
|
@@ -1065,7 +1195,11 @@ def zip_response(doc_ids: list[str]):
|
|
| 1065 |
|
| 1066 |
def run_search(body: SearchRequest, sources_filter=None, timings=None):
|
| 1067 |
selected_sources = sources_filter if sources_filter is not None else body.sources
|
| 1068 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1069 |
key = (
|
| 1070 |
"search",
|
| 1071 |
fulltext_generation,
|
|
@@ -1081,7 +1215,7 @@ def run_search(body: SearchRequest, sources_filter=None, timings=None):
|
|
| 1081 |
body.search_paths,
|
| 1082 |
body.fulltext,
|
| 1083 |
)
|
| 1084 |
-
result =
|
| 1085 |
key,
|
| 1086 |
lambda: fulltext_search(
|
| 1087 |
body.q, selected_sources, body.folders, body.min_size, body.max_size,
|
|
|
|
| 13 |
import time
|
| 14 |
import unicodedata
|
| 15 |
from functools import lru_cache
|
| 16 |
+
from contextlib import asynccontextmanager, contextmanager
|
| 17 |
from pathlib import Path
|
| 18 |
from typing import Literal, Optional
|
| 19 |
from urllib.parse import parse_qs, quote, unquote
|
|
|
|
| 32 |
INDEX_BUILD_STATUS_PATH = FULLTEXT_DIR / "build-status.json"
|
| 33 |
BUCKET_INDEX_STATUS_PATH = FULLTEXT_DIR / "bucket-index-status.json"
|
| 34 |
records: list[dict] = []
|
| 35 |
+
name_order: list[int] = []
|
| 36 |
+
size_order: list[int] = []
|
| 37 |
record_map: dict[str, dict] = {}
|
| 38 |
record_map_index: dict[str, int] = {}
|
| 39 |
sources: list[dict] = []
|
|
|
|
| 49 |
LITERAL_PREFIX = "\0literal:"
|
| 50 |
API_CACHE_TTL_SECONDS = 120
|
| 51 |
SEARCH_CACHE_TTL_SECONDS = 300
|
| 52 |
+
API_CACHE_MAX_ENTRIES = 500
|
| 53 |
+
SEARCH_CACHE_MAX_ENTRIES = 500
|
| 54 |
api_response_cache: dict[tuple, tuple[float, object]] = {}
|
| 55 |
api_response_cache_lock = threading.Lock()
|
| 56 |
+
search_response_cache: dict[tuple, tuple[float, object]] = {}
|
| 57 |
+
search_response_cache_lock = threading.Lock()
|
| 58 |
ZIP_TOKEN_TTL_SECONDS = 600
|
| 59 |
ZIP_TOKEN_MAX_ENTRIES = 32
|
| 60 |
zip_download_tokens: dict[str, tuple[float, list[str]]] = {}
|
|
|
|
| 116 |
self.directory = directory
|
| 117 |
self.in_memory = in_memory
|
| 118 |
self.lock = threading.RLock()
|
| 119 |
+
self.refresh_lock = threading.Lock()
|
| 120 |
self.connections = {}
|
| 121 |
self.has_doc_counts = {}
|
| 122 |
self.tokenizer_versions = {}
|
|
|
|
| 125 |
self.document_counts = {}
|
| 126 |
self.fts_counts = {}
|
| 127 |
self.file_signatures = {}
|
| 128 |
+
self.connection_readers = {}
|
| 129 |
+
self.retired_connections = set()
|
| 130 |
+
self.closed = False
|
| 131 |
self.generation = 0
|
| 132 |
if refresh:
|
| 133 |
self.refresh()
|
| 134 |
|
| 135 |
def refresh(self) -> None:
|
| 136 |
+
with self.refresh_lock:
|
| 137 |
for path in self.directory.glob("*.sqlite3"):
|
| 138 |
try:
|
| 139 |
stat = path.stat()
|
| 140 |
signature = (stat.st_ino, stat.st_size, stat.st_mtime_ns)
|
| 141 |
except OSError:
|
| 142 |
continue
|
| 143 |
+
with self.lock:
|
| 144 |
+
if self.closed or self.file_signatures.get(path.stem) == signature:
|
| 145 |
+
continue
|
| 146 |
try:
|
| 147 |
+
candidate = self._open_candidate(path)
|
| 148 |
except (OSError, sqlite3.Error) as exc:
|
| 149 |
print(f"fulltext_open_failed={path.name}:{exc}")
|
| 150 |
+
continue
|
| 151 |
+
try:
|
| 152 |
+
current_stat = path.stat()
|
| 153 |
+
current_signature = (current_stat.st_ino, current_stat.st_size, current_stat.st_mtime_ns)
|
| 154 |
+
except OSError:
|
| 155 |
+
candidate["connection"].close()
|
| 156 |
+
continue
|
| 157 |
+
if current_signature != signature:
|
| 158 |
+
candidate["connection"].close()
|
| 159 |
+
continue
|
| 160 |
+
with self.lock:
|
| 161 |
+
if self.closed or self.file_signatures.get(path.stem) == signature:
|
| 162 |
+
candidate["connection"].close()
|
| 163 |
+
continue
|
| 164 |
+
self._install_candidate(path.stem, signature, candidate)
|
| 165 |
|
| 166 |
+
def _open_candidate(self, path: Path) -> dict:
|
| 167 |
uri = f"file:{path.resolve()}?mode=ro&immutable=1"
|
| 168 |
source_connection = sqlite3.connect(uri, uri=True, check_same_thread=False)
|
| 169 |
connection = source_connection
|
|
|
|
| 190 |
except Exception:
|
| 191 |
if connection is not source_connection:
|
| 192 |
connection.close()
|
| 193 |
+
source_connection.close()
|
| 194 |
raise
|
| 195 |
finally:
|
| 196 |
if source_connection is not connection:
|
| 197 |
source_connection.close()
|
| 198 |
+
return {
|
| 199 |
+
"connection": connection,
|
| 200 |
+
"has_doc_counts": "doc_count" in columns,
|
| 201 |
+
"tokenizer_version": version_row[0] if version_row else "legacy",
|
| 202 |
+
"has_content_fts": has_content_fts,
|
| 203 |
+
"has_snippet_anchors": has_snippet_anchors,
|
| 204 |
+
"document_count": document_count,
|
| 205 |
+
"fts_count": fts_count,
|
| 206 |
+
}
|
| 207 |
+
|
| 208 |
+
def _install_candidate(self, source: str, signature: tuple[int, int, int], candidate: dict) -> None:
|
| 209 |
+
connection = candidate["connection"]
|
| 210 |
+
old_connection = self.connections.get(source)
|
| 211 |
+
self.connections[source] = connection
|
| 212 |
+
self.has_doc_counts[source] = candidate["has_doc_counts"]
|
| 213 |
+
self.tokenizer_versions[source] = candidate["tokenizer_version"]
|
| 214 |
+
self.has_content_fts[source] = candidate["has_content_fts"]
|
| 215 |
+
self.has_snippet_anchors[source] = candidate["has_snippet_anchors"]
|
| 216 |
+
self.document_counts[source] = candidate["document_count"]
|
| 217 |
+
self.fts_counts[source] = candidate["fts_count"]
|
| 218 |
+
self.file_signatures[source] = signature
|
| 219 |
self.generation += 1
|
| 220 |
if old_connection is not None:
|
| 221 |
+
if self.connection_readers.get(old_connection, 0):
|
| 222 |
+
self.retired_connections.add(old_connection)
|
| 223 |
+
else:
|
| 224 |
+
old_connection.close()
|
| 225 |
|
| 226 |
+
@contextmanager
|
| 227 |
+
def _lease_source(self, source: str):
|
| 228 |
with self.lock:
|
| 229 |
+
connection = self.connections.get(source)
|
| 230 |
+
if connection is None or self.closed:
|
| 231 |
+
lease = None
|
| 232 |
+
else:
|
| 233 |
+
self.connection_readers[connection] = self.connection_readers.get(connection, 0) + 1
|
| 234 |
+
lease = (connection, {
|
| 235 |
+
"has_doc_counts": self.has_doc_counts.get(source, False),
|
| 236 |
+
"tokenizer_version": self.tokenizer_versions.get(source),
|
| 237 |
+
"has_content_fts": self.has_content_fts.get(source, False),
|
| 238 |
+
"has_snippet_anchors": self.has_snippet_anchors.get(source, False),
|
| 239 |
+
})
|
| 240 |
+
try:
|
| 241 |
+
yield lease
|
| 242 |
+
finally:
|
| 243 |
+
if lease is None:
|
| 244 |
+
return
|
| 245 |
+
with self.lock:
|
| 246 |
+
remaining = self.connection_readers.get(connection, 1) - 1
|
| 247 |
+
if remaining:
|
| 248 |
+
self.connection_readers[connection] = remaining
|
| 249 |
+
else:
|
| 250 |
+
self.connection_readers.pop(connection, None)
|
| 251 |
+
if connection in self.retired_connections:
|
| 252 |
+
self.retired_connections.remove(connection)
|
| 253 |
+
connection.close()
|
| 254 |
+
|
| 255 |
+
def close(self) -> None:
|
| 256 |
+
with self.refresh_lock:
|
| 257 |
+
with self.lock:
|
| 258 |
+
self.closed = True
|
| 259 |
+
connections = set(self.connections.values()) | self.retired_connections
|
| 260 |
+
self.connections.clear()
|
| 261 |
+
self.retired_connections = {
|
| 262 |
+
connection for connection in connections
|
| 263 |
+
if self.connection_readers.get(connection, 0)
|
| 264 |
+
}
|
| 265 |
+
for connection in connections - self.retired_connections:
|
| 266 |
+
connection.close()
|
| 267 |
|
| 268 |
def search_source(self, source: str, query: str, exact: bool = False) -> set[int]:
|
| 269 |
with self.lock:
|
| 270 |
+
missing = source not in self.connections
|
| 271 |
+
if missing:
|
| 272 |
+
self.refresh()
|
| 273 |
+
with self._lease_source(source) as lease:
|
| 274 |
+
if lease is None:
|
| 275 |
return set()
|
| 276 |
+
connection, state = lease
|
| 277 |
+
return self._search_source_connection(connection, query, exact, state)
|
| 278 |
+
|
| 279 |
+
def _search_source_connection(self, connection: sqlite3.Connection, query: str, exact: bool, state: dict) -> set[int]:
|
| 280 |
+
version = state["tokenizer_version"]
|
| 281 |
+
has_content_fts = state["has_content_fts"]
|
| 282 |
+
has_doc_counts = state["has_doc_counts"]
|
| 283 |
if exact and has_content_fts:
|
| 284 |
normalized_query = normalize_text(query)
|
| 285 |
try:
|
|
|
|
| 311 |
"SELECT rowid FROM content_fts WHERE instr(content, ?) > 0", (normalized_query,)
|
| 312 |
)
|
| 313 |
return {int(row[0]) for row in rows}
|
| 314 |
+
candidates = self._search_postings(connection, has_doc_counts, tokens)
|
| 315 |
return candidates
|
| 316 |
except sqlite3.OperationalError:
|
| 317 |
pass
|
|
|
|
| 350 |
result = self.verify_wildcard_content(connection, result, query)
|
| 351 |
return result
|
| 352 |
|
| 353 |
+
def _search_postings(self, connection: sqlite3.Connection, has_doc_counts: bool, tokens: list[str]) -> set[int]:
|
| 354 |
if not tokens:
|
| 355 |
return set()
|
| 356 |
matched = None
|
| 357 |
token_hashes = [hashlib.sha256(token.encode("utf-8")).digest()[:16] for token in tokens]
|
| 358 |
+
if has_doc_counts:
|
| 359 |
placeholders = ",".join("?" for _ in token_hashes)
|
| 360 |
rows = connection.execute(
|
| 361 |
f"SELECT token_hash, docs, doc_count FROM postings WHERE token_hash IN ({placeholders})",
|
|
|
|
| 432 |
return matched
|
| 433 |
|
| 434 |
def can_trust_exact_content(self, source: str, query: str) -> bool:
|
| 435 |
+
with self.lock:
|
| 436 |
+
return (
|
| 437 |
+
self.tokenizer_versions.get(source) == TOKENIZER_VERSION
|
| 438 |
+
and self.has_content_fts.get(source, False)
|
| 439 |
+
and "*" not in query
|
| 440 |
+
and "?" not in query
|
| 441 |
+
)
|
| 442 |
|
| 443 |
def summaries(self, doc_ids: list[str]) -> dict[str, str]:
|
|
|
|
| 444 |
grouped = {}
|
| 445 |
for doc_id in doc_ids:
|
| 446 |
grouped.setdefault(doc_id.split(":", 1)[0], []).append(doc_id)
|
| 447 |
+
with self.lock:
|
| 448 |
+
missing = any(source not in self.connections for source in grouped)
|
| 449 |
+
if missing:
|
| 450 |
+
self.refresh()
|
| 451 |
summaries = {}
|
| 452 |
for source, source_doc_ids in grouped.items():
|
| 453 |
+
with self._lease_source(source) as lease:
|
| 454 |
+
if lease is None:
|
| 455 |
+
continue
|
| 456 |
+
connection, _state = lease
|
| 457 |
+
for offset in range(0, len(source_doc_ids), 500):
|
| 458 |
+
batch = source_doc_ids[offset:offset + 500]
|
| 459 |
+
rows = connection.execute(
|
| 460 |
+
f"SELECT doc_id, summary FROM documents WHERE doc_id IN ({','.join('?' for _ in batch)})",
|
| 461 |
+
batch,
|
| 462 |
+
)
|
| 463 |
+
summaries.update(rows)
|
| 464 |
return summaries
|
| 465 |
|
| 466 |
def matched_snippets(self, doc_ids: list[str], query: str, exact: bool = False, timings=None) -> dict[str, dict]:
|
|
|
|
| 467 |
normalized_query = normalize_text(query.strip())
|
| 468 |
# A fixed wildcard fragment can occur long before the actual match.
|
| 469 |
# Let the original-text snippet endpoint locate the verified span.
|
|
|
|
| 480 |
source, _, number = doc_id.partition(":")
|
| 481 |
if number.isdigit():
|
| 482 |
grouped.setdefault(source, []).append((doc_id, int(number)))
|
| 483 |
+
with self.lock:
|
| 484 |
+
missing = any(source not in self.connections for source in grouped)
|
| 485 |
+
if missing:
|
| 486 |
+
self.refresh()
|
| 487 |
snippets = {}
|
| 488 |
for source, source_docs in grouped.items():
|
| 489 |
+
with self._lease_source(source) as lease:
|
| 490 |
+
if lease is None:
|
| 491 |
+
continue
|
| 492 |
+
connection, state = lease
|
| 493 |
+
if not state["has_content_fts"]:
|
| 494 |
+
continue
|
| 495 |
+
self._matched_snippets_source(
|
| 496 |
+
snippets, connection, state, source_docs, needle, query, exact, timings,
|
| 497 |
+
)
|
| 498 |
+
return snippets
|
| 499 |
+
|
| 500 |
+
def _matched_snippets_source(self, snippets, connection, state, source_docs, needle, query, exact, timings) -> None:
|
| 501 |
+
doc_id_by_number = {number: doc_id for doc_id, number in source_docs}
|
| 502 |
+
numbers = list(doc_id_by_number)
|
| 503 |
+
anchored_numbers = set()
|
| 504 |
+
# Exact snippets retain the established literal path. Normal snippets can seek
|
| 505 |
+
# directly to a token anchor without scanning FTS content with instr().
|
| 506 |
+
if not exact and state["has_snippet_anchors"]:
|
| 507 |
+
token_hash = hashlib.sha256(needle.encode("utf-8")).digest()[:16]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 508 |
for offset in range(0, len(numbers), 500):
|
| 509 |
batch = numbers[offset:offset + 500]
|
| 510 |
placeholders = ",".join("?" for _ in batch)
|
| 511 |
try:
|
| 512 |
+
anchor_started = time.perf_counter()
|
| 513 |
rows = connection.execute(
|
| 514 |
f"""
|
| 515 |
+
SELECT content_fts.rowid, snippet_anchors.char_offset, length(content),
|
| 516 |
+
substr(content, max(snippet_anchors.char_offset - 219, 1), 440 + length(?))
|
| 517 |
+
FROM content_fts JOIN snippet_anchors ON snippet_anchors.doc_number = content_fts.rowid
|
| 518 |
+
WHERE snippet_anchors.token_hash = ? AND content_fts.rowid IN ({placeholders})
|
| 519 |
""",
|
| 520 |
+
[needle, token_hash, *batch],
|
| 521 |
).fetchall()
|
| 522 |
if timings is not None:
|
| 523 |
+
timings["snippet_anchor_sql"] = timings.get("snippet_anchor_sql", 0) + (time.perf_counter() - anchor_started) * 1000
|
| 524 |
+
for number, char_offset, content_length, text in rows:
|
| 525 |
payload_started = time.perf_counter()
|
| 526 |
+
payload = build_normalized_snippet_payload(text or "", query)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 527 |
if timings is not None:
|
| 528 |
timings["snippet_payload"] = timings.get("snippet_payload", 0) + (time.perf_counter() - payload_started) * 1000
|
| 529 |
+
if not payload["highlights"]:
|
| 530 |
+
continue
|
| 531 |
+
extracted_start = max(int(char_offset) - 219, 1)
|
| 532 |
+
if extracted_start > 1 and not payload["snippet"].startswith("..."):
|
| 533 |
payload["snippet"] = "..." + payload["snippet"]
|
| 534 |
+
if extracted_start + len(text or "") <= int(content_length or 0) and not payload["snippet"].endswith("..."):
|
| 535 |
payload["snippet"] += "..."
|
| 536 |
snippets[doc_id_by_number[int(number)]] = payload
|
| 537 |
+
anchored_numbers.add(int(number))
|
| 538 |
except sqlite3.OperationalError:
|
| 539 |
+
break
|
| 540 |
+
numbers = [number for number in numbers if number not in anchored_numbers]
|
| 541 |
+
for offset in range(0, len(numbers), 500):
|
| 542 |
+
batch = numbers[offset:offset + 500]
|
| 543 |
+
placeholders = ",".join("?" for _ in batch)
|
| 544 |
+
try:
|
| 545 |
+
fallback_started = time.perf_counter()
|
| 546 |
+
rows = connection.execute(
|
| 547 |
+
f"""
|
| 548 |
+
SELECT rowid, instr(content, ?) AS match_position, length(content),
|
| 549 |
+
substr(content, max(instr(content, ?) - 220, 1), 440 + length(?))
|
| 550 |
+
FROM content_fts
|
| 551 |
+
WHERE rowid IN ({placeholders})
|
| 552 |
+
""",
|
| 553 |
+
[needle, needle, needle, *batch],
|
| 554 |
+
).fetchall()
|
| 555 |
+
if timings is not None:
|
| 556 |
+
timings["snippet_fallback_sql"] = timings.get("snippet_fallback_sql", 0) + (time.perf_counter() - fallback_started) * 1000
|
| 557 |
+
for number, match_position, content_length, text in rows:
|
| 558 |
+
payload_started = time.perf_counter()
|
| 559 |
+
extracted_start = max(int(match_position or 0) - 220, 1)
|
| 560 |
+
if exact and not has_wildcard_query(query):
|
| 561 |
+
payload = build_normalized_exact_snippet_payload(text or "", query)
|
| 562 |
+
if not payload["highlights"]:
|
| 563 |
+
payload = build_snippet_payload(text or "", query, exact=True)
|
| 564 |
+
else:
|
| 565 |
+
payload = build_snippet_payload(text or "", query, exact=exact)
|
| 566 |
+
if not payload["highlights"]:
|
| 567 |
+
payload = build_snippet_payload(text or "", needle, exact=True)
|
| 568 |
+
if timings is not None:
|
| 569 |
+
timings["snippet_payload"] = timings.get("snippet_payload", 0) + (time.perf_counter() - payload_started) * 1000
|
| 570 |
+
if payload["snippet"] and extracted_start > 1 and not payload["snippet"].startswith("..."):
|
| 571 |
+
payload["snippet"] = "..." + payload["snippet"]
|
| 572 |
+
if payload["snippet"] and extracted_start + len(text or "") <= int(content_length or 0) and not payload["snippet"].endswith("..."):
|
| 573 |
+
payload["snippet"] += "..."
|
| 574 |
+
snippets[doc_id_by_number[int(number)]] = payload
|
| 575 |
+
except sqlite3.OperationalError:
|
| 576 |
+
continue
|
| 577 |
fulltext_databases: Optional[FulltextDatabases] = None
|
| 578 |
|
| 579 |
def tokenize(text: str) -> list[str]:
|
|
|
|
| 705 |
return json.loads(gzip.decompress(path.read_bytes()).decode("utf-8"))
|
| 706 |
|
| 707 |
def build_indexes() -> None:
|
| 708 |
+
global word_index, source_records_map, extension_counts, source_extension_counts, name_order, size_order
|
| 709 |
word_index = {}
|
| 710 |
source_records_map = {}
|
| 711 |
extension_counts = {}
|
|
|
|
| 726 |
rec["_rank_path_key"] = rec["display_rel_path"].lower()
|
| 727 |
for token in tokens:
|
| 728 |
word_index.setdefault(token, set()).add(idx)
|
| 729 |
+
name_order = sorted(
|
| 730 |
+
range(len(records)),
|
| 731 |
+
key=lambda idx: (records[idx]["display_rel_path"].lower(), records[idx]["doc_id"]),
|
| 732 |
+
)
|
| 733 |
+
size_order = sorted(
|
| 734 |
+
range(len(records)),
|
| 735 |
+
key=lambda idx: (
|
| 736 |
+
-(records[idx].get("size") or 0),
|
| 737 |
+
records[idx]["display_rel_path"].lower(),
|
| 738 |
+
records[idx]["doc_id"],
|
| 739 |
+
),
|
| 740 |
+
)
|
| 741 |
|
| 742 |
def load_data() -> None:
|
| 743 |
global records, record_map, record_map_index, sources, source_counts, folder_tree_data, folder_browser_data
|
|
|
|
| 775 |
)
|
| 776 |
return ranked[start:limit]
|
| 777 |
|
| 778 |
+
def ordered_page(indices, ordering: list[int], start: int, page_size: int) -> list[int]:
|
| 779 |
+
candidates = set(indices)
|
| 780 |
+
if not candidates or page_size <= 0:
|
| 781 |
+
return []
|
| 782 |
+
selected = []
|
| 783 |
+
matched = 0
|
| 784 |
+
for idx in ordering:
|
| 785 |
+
if idx not in candidates:
|
| 786 |
+
continue
|
| 787 |
+
if matched >= start:
|
| 788 |
+
selected.append(idx)
|
| 789 |
+
if len(selected) >= page_size:
|
| 790 |
+
break
|
| 791 |
+
matched += 1
|
| 792 |
+
return selected
|
| 793 |
+
|
| 794 |
def apply_filters(indices: list[int], sources_filter=None, folders=None, min_size=None, max_size=None):
|
| 795 |
result = []
|
| 796 |
for idx in indices:
|
|
|
|
| 943 |
paged_indices = relevance_page(filtered, tokens, search_paths, start, page_size)
|
| 944 |
if timings is not None:
|
| 945 |
timings["rank"] = (time.perf_counter() - rank_started) * 1000
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 946 |
total = len(filtered)
|
| 947 |
if q and sort == "relevance":
|
| 948 |
selected = paged_indices
|
| 949 |
+
elif sort == "name":
|
| 950 |
+
sort_started = time.perf_counter()
|
| 951 |
+
selected = ordered_page(filtered, name_order, start, page_size)
|
| 952 |
+
if timings is not None:
|
| 953 |
+
timings["sort"] = (time.perf_counter() - sort_started) * 1000
|
| 954 |
+
elif sort == "size":
|
| 955 |
+
sort_started = time.perf_counter()
|
| 956 |
+
selected = ordered_page(filtered, size_order, start, page_size)
|
| 957 |
+
if timings is not None:
|
| 958 |
+
timings["sort"] = (time.perf_counter() - sort_started) * 1000
|
| 959 |
else:
|
| 960 |
selected = filtered[start:start + page_size]
|
| 961 |
result_items = [trim_record(records[idx]) for idx in selected]
|
|
|
|
| 1000 |
timings["rank"] = (time.perf_counter() - rank_started) * 1000
|
| 1001 |
elif sort == "name":
|
| 1002 |
sort_started = time.perf_counter()
|
| 1003 |
+
selected = ordered_page(filtered, name_order, (page - 1) * page_size, page_size)
|
| 1004 |
if timings is not None:
|
| 1005 |
timings["sort"] = (time.perf_counter() - sort_started) * 1000
|
| 1006 |
elif sort == "size":
|
| 1007 |
sort_started = time.perf_counter()
|
| 1008 |
+
selected = ordered_page(filtered, size_order, (page - 1) * page_size, page_size)
|
| 1009 |
if timings is not None:
|
| 1010 |
timings["sort"] = (time.perf_counter() - sort_started) * 1000
|
| 1011 |
total = len(filtered)
|
|
|
|
|
|
|
|
|
|
| 1012 |
summaries_started = time.perf_counter()
|
| 1013 |
result_items = add_summaries(
|
| 1014 |
[trim_record(records[idx]) for idx in selected],
|
|
|
|
| 1034 |
return entry
|
| 1035 |
return {"folders": [], "files": [], "current_path": path}
|
| 1036 |
|
| 1037 |
+
def _cached_payload(cache, lock, key: tuple, builder, ttl: int, max_entries: int, timings=None):
|
| 1038 |
cache_started = time.perf_counter()
|
| 1039 |
now = time.monotonic()
|
| 1040 |
+
with lock:
|
| 1041 |
+
cached = cache.get(key)
|
|
|
|
| 1042 |
if cached and now - cached[0] < ttl:
|
| 1043 |
if timings is not None:
|
| 1044 |
timings["cache"] = "hit"
|
|
|
|
| 1048 |
timings["cache"] = "miss"
|
| 1049 |
timings["cache_lookup"] = (time.perf_counter() - cache_started) * 1000
|
| 1050 |
value = builder()
|
| 1051 |
+
inserted_at = time.monotonic()
|
| 1052 |
+
with lock:
|
| 1053 |
+
cache[key] = (inserted_at, value)
|
| 1054 |
+
while len(cache) > max_entries:
|
| 1055 |
+
oldest_key = min(cache, key=lambda item: cache[item][0])
|
| 1056 |
+
cache.pop(oldest_key, None)
|
| 1057 |
return value
|
| 1058 |
|
| 1059 |
+
def cached_payload(key: tuple, builder, timings=None):
|
| 1060 |
+
return _cached_payload(
|
| 1061 |
+
api_response_cache, api_response_cache_lock, key, builder,
|
| 1062 |
+
API_CACHE_TTL_SECONDS, API_CACHE_MAX_ENTRIES, timings,
|
| 1063 |
+
)
|
| 1064 |
+
|
| 1065 |
+
def cached_search_payload(key: tuple, builder, timings=None):
|
| 1066 |
+
return _cached_payload(
|
| 1067 |
+
search_response_cache, search_response_cache_lock, key, builder,
|
| 1068 |
+
SEARCH_CACHE_TTL_SECONDS, SEARCH_CACHE_MAX_ENTRIES, timings,
|
| 1069 |
+
)
|
| 1070 |
+
|
| 1071 |
def ready_fulltext_sources() -> set[str]:
|
| 1072 |
if fulltext_databases is None:
|
| 1073 |
return set()
|
| 1074 |
+
with fulltext_databases.lock:
|
| 1075 |
+
return {
|
| 1076 |
+
source for source in fulltext_databases.connections
|
| 1077 |
+
if fulltext_databases.tokenizer_versions.get(source) == TOKENIZER_VERSION
|
| 1078 |
+
and fulltext_databases.has_content_fts.get(source, False)
|
| 1079 |
+
and fulltext_databases.has_snippet_anchors.get(source, False)
|
| 1080 |
+
and fulltext_databases.document_counts.get(source) == source_counts.get(source)
|
| 1081 |
+
and fulltext_databases.fts_counts.get(source) == source_counts.get(source)
|
| 1082 |
+
}
|
| 1083 |
@asynccontextmanager
|
| 1084 |
|
| 1085 |
async def lifespan(app: FastAPI):
|
|
|
|
| 1195 |
|
| 1196 |
def run_search(body: SearchRequest, sources_filter=None, timings=None):
|
| 1197 |
selected_sources = sources_filter if sources_filter is not None else body.sources
|
| 1198 |
+
if fulltext_databases is None:
|
| 1199 |
+
fulltext_generation = 0
|
| 1200 |
+
else:
|
| 1201 |
+
with fulltext_databases.lock:
|
| 1202 |
+
fulltext_generation = fulltext_databases.generation
|
| 1203 |
key = (
|
| 1204 |
"search",
|
| 1205 |
fulltext_generation,
|
|
|
|
| 1215 |
body.search_paths,
|
| 1216 |
body.fulltext,
|
| 1217 |
)
|
| 1218 |
+
result = cached_search_payload(
|
| 1219 |
key,
|
| 1220 |
lambda: fulltext_search(
|
| 1221 |
body.q, selected_sources, body.folders, body.min_size, body.max_size,
|
prepare_fulltext_index.py
CHANGED
|
@@ -94,20 +94,18 @@ def main() -> int:
|
|
| 94 |
if not isinstance(databases, dict):
|
| 95 |
raise ValueError("missing databases manifest")
|
| 96 |
paths = {source: validated_database(bucket, source, databases.get(source)) for source in SOURCES}
|
|
|
|
|
|
|
|
|
|
|
|
|
| 97 |
except Exception as exc:
|
| 98 |
pointer_error = {"error": type(exc).__name__, "detail": str(exc)}
|
| 99 |
paths = None
|
| 100 |
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
if paths:
|
| 106 |
-
parents = {path.parent.name for path, _size in paths.values()}
|
| 107 |
-
if len(parents) == 1:
|
| 108 |
-
manifest_generation = parents.pop()
|
| 109 |
-
if paths is None or generation != manifest_generation:
|
| 110 |
-
paths = discovered_paths
|
| 111 |
write_status(state="checking", generation=generation, source="bucket_scan", pointer_error=pointer_error)
|
| 112 |
|
| 113 |
if paths is None:
|
|
|
|
| 94 |
if not isinstance(databases, dict):
|
| 95 |
raise ValueError("missing databases manifest")
|
| 96 |
paths = {source: validated_database(bucket, source, databases.get(source)) for source in SOURCES}
|
| 97 |
+
if len({path.parent for path, _size in paths.values()}) != 1:
|
| 98 |
+
raise ValueError("databases span multiple generations")
|
| 99 |
+
if any(path.stat().st_size != expected_bytes for path, expected_bytes in paths.values()):
|
| 100 |
+
raise ValueError("database size does not match manifest")
|
| 101 |
except Exception as exc:
|
| 102 |
pointer_error = {"error": type(exc).__name__, "detail": str(exc)}
|
| 103 |
paths = None
|
| 104 |
|
| 105 |
+
if paths is None:
|
| 106 |
+
discovered = discover_latest_generation(bucket)
|
| 107 |
+
if discovered is not None:
|
| 108 |
+
generation, paths = discovered
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 109 |
write_status(state="checking", generation=generation, source="bucket_scan", pointer_error=pointer_error)
|
| 110 |
|
| 111 |
if paths is None:
|
static/sw.js
CHANGED
|
@@ -1,32 +1,15 @@
|
|
| 1 |
const CACHE_NAME = "vomebook-search-v1.0.0";
|
| 2 |
|
| 3 |
const PRECACHE_URLS = [
|
| 4 |
-
"/",
|
| 5 |
"/static/style.css",
|
| 6 |
"/static/app.js",
|
| 7 |
-
"/manifest.json"
|
| 8 |
-
"/data/initial/manifest.json",
|
| 9 |
-
"/data/sidebar/manifest.json"
|
| 10 |
];
|
| 11 |
|
| 12 |
-
function cacheManifestUrls(cache, manifestUrl) {
|
| 13 |
-
return fetch(manifestUrl)
|
| 14 |
-
.then((response) => response.ok ? response.json() : null)
|
| 15 |
-
.then((manifest) => {
|
| 16 |
-
const urls = manifest && Array.isArray(manifest.urls) ? manifest.urls : [];
|
| 17 |
-
return urls.length ? cache.addAll(urls) : null;
|
| 18 |
-
});
|
| 19 |
-
}
|
| 20 |
-
|
| 21 |
self.addEventListener("install", (event) => {
|
| 22 |
event.waitUntil(
|
| 23 |
caches.open(CACHE_NAME).then((cache) => {
|
| 24 |
-
return cache.addAll(PRECACHE_URLS).
|
| 25 |
-
return Promise.all([
|
| 26 |
-
cacheManifestUrls(cache, "/data/initial/manifest.json"),
|
| 27 |
-
cacheManifestUrls(cache, "/data/sidebar/manifest.json")
|
| 28 |
-
]);
|
| 29 |
-
}).catch((err) => {
|
| 30 |
console.warn("[SW] precache partial failure:", err);
|
| 31 |
});
|
| 32 |
}).then(() => self.skipWaiting())
|
|
|
|
| 1 |
const CACHE_NAME = "vomebook-search-v1.0.0";
|
| 2 |
|
| 3 |
const PRECACHE_URLS = [
|
|
|
|
| 4 |
"/static/style.css",
|
| 5 |
"/static/app.js",
|
| 6 |
+
"/manifest.json"
|
|
|
|
|
|
|
| 7 |
];
|
| 8 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 9 |
self.addEventListener("install", (event) => {
|
| 10 |
event.waitUntil(
|
| 11 |
caches.open(CACHE_NAME).then((cache) => {
|
| 12 |
+
return cache.addAll(PRECACHE_URLS).catch((err) => {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 13 |
console.warn("[SW] precache partial failure:", err);
|
| 14 |
});
|
| 15 |
}).then(() => self.skipWaiting())
|