vomebook commited on
Commit
3cea36f
·
verified ·
1 Parent(s): 9d38f16

Optimize sorting, index loading, and caches

Browse files
Files changed (3) hide show
  1. app.py +288 -154
  2. prepare_fulltext_index.py +8 -10
  3. 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.retired_connections = []
 
 
122
  self.generation = 0
123
  if refresh:
124
  self.refresh()
125
 
126
  def refresh(self) -> None:
127
- with self.lock:
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
- if self.file_signatures.get(path.stem) == signature:
135
- continue
 
136
  try:
137
- self._open(path, signature)
138
  except (OSError, sqlite3.Error) as exc:
139
  print(f"fulltext_open_failed={path.name}:{exc}")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
140
 
141
- def _open(self, path: Path, signature: tuple[int, int, int] | None = None) -> None:
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
- connection.close()
169
  raise
170
  finally:
171
  if source_connection is not connection:
172
  source_connection.close()
173
- old_connection = self.connections.get(path.stem)
174
- self.connections[path.stem] = connection
175
- self.has_doc_counts[path.stem] = "doc_count" in columns
176
- self.tokenizer_versions[path.stem] = version_row[0] if version_row else "legacy"
177
- self.has_content_fts[path.stem] = has_content_fts
178
- self.has_snippet_anchors[path.stem] = has_snippet_anchors
179
- self.document_counts[path.stem] = document_count
180
- self.fts_counts[path.stem] = fts_count
181
- if signature is None:
182
- stat = path.stat()
183
- signature = (stat.st_ino, stat.st_size, stat.st_mtime_ns)
184
- self.file_signatures[path.stem] = signature
 
 
 
 
 
 
 
 
 
185
  self.generation += 1
186
  if old_connection is not None:
187
- # An in-flight request may still be reading the prior immutable file.
188
- self.retired_connections.append(old_connection)
189
- while len(self.retired_connections) > 3:
190
- self.retired_connections.pop(0).close()
191
 
192
- def close(self) -> None:
 
193
  with self.lock:
194
- for connection in self.connections.values():
195
- connection.close()
196
- for connection in self.retired_connections:
197
- connection.close()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
198
 
199
  def search_source(self, source: str, query: str, exact: bool = False) -> set[int]:
200
  with self.lock:
201
- connection = self.connections.get(source)
202
- if connection is None:
203
- self.refresh()
204
- connection = self.connections.get(source)
205
- if connection is None:
206
  return set()
207
- version = self.tokenizer_versions.get(source)
208
- has_content_fts = self.has_content_fts.get(source)
209
- has_doc_counts = self.has_doc_counts.get(source)
 
 
 
 
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, source, tokens)
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, source: str, tokens: list[str]) -> set[int]:
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 self.has_doc_counts.get(source):
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
- return (
363
- self.tokenizer_versions.get(source) == TOKENIZER_VERSION
364
- and self.has_content_fts.get(source, False)
365
- and "*" not in query
366
- and "?" not in query
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
- connection = self.connections.get(source)
377
- if connection is None:
378
- continue
379
- for offset in range(0, len(source_doc_ids), 500):
380
- batch = source_doc_ids[offset:offset + 500]
381
- rows = connection.execute(
382
- f"SELECT doc_id, summary FROM documents WHERE doc_id IN ({','.join('?' for _ in batch)})",
383
- batch,
384
- )
385
- summaries.update(rows)
 
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
- connection = self.connections.get(source)
409
- if connection is None or not self.has_content_fts.get(source, False):
410
- continue
411
- doc_id_by_number = {number: doc_id for doc_id, number in source_docs}
412
- numbers = list(doc_id_by_number)
413
- anchored_numbers = set()
414
- # Exact snippets retain the established literal/wildcard path. Normal snippets
415
- # can seek directly to a token anchor without scanning FTS content with instr().
416
- if not exact and self.has_snippet_anchors.get(source, False):
417
- token_hash = hashlib.sha256(needle.encode("utf-8")).digest()[:16]
418
- for offset in range(0, len(numbers), 500):
419
- batch = numbers[offset:offset + 500]
420
- placeholders = ",".join("?" for _ in batch)
421
- try:
422
- anchor_started = time.perf_counter()
423
- rows = connection.execute(
424
- f"""
425
- SELECT content_fts.rowid, snippet_anchors.char_offset, length(content),
426
- substr(content, max(snippet_anchors.char_offset - 219, 1), 440 + length(?))
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
- fallback_started = time.perf_counter()
456
  rows = connection.execute(
457
  f"""
458
- SELECT rowid, instr(content, ?) AS match_position, length(content),
459
- substr(content, max(instr(content, ?) - 220, 1), 440 + length(?))
460
- FROM content_fts
461
- WHERE rowid IN ({placeholders})
462
  """,
463
- [needle, needle, needle, *batch],
464
  ).fetchall()
465
  if timings is not None:
466
- timings["snippet_fallback_sql"] = timings.get("snippet_fallback_sql", 0) + (time.perf_counter() - fallback_started) * 1000
467
- for number, match_position, content_length, text in rows:
468
  payload_started = time.perf_counter()
469
- extracted_start = max(int(match_position or 0) - 220, 1)
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 payload["snippet"] and extracted_start > 1 and not payload["snippet"].startswith("..."):
 
 
 
481
  payload["snippet"] = "..." + payload["snippet"]
482
- if payload["snippet"] and extracted_start + len(text or "") <= int(content_length or 0) and not payload["snippet"].endswith("..."):
483
  payload["snippet"] += "..."
484
  snippets[doc_id_by_number[int(number)]] = payload
 
485
  except sqlite3.OperationalError:
486
- continue
487
- return snippets
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- filtered.sort(key=lambda idx: (records[idx]["display_rel_path"].lower(), records[idx]["doc_id"]))
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
- filtered.sort(key=lambda idx: (-records[idx].get("size", 0), records[idx]["display_rel_path"].lower(), records[idx]["doc_id"]))
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 cached_payload(key: tuple, builder, timings=None):
921
  cache_started = time.perf_counter()
922
  now = time.monotonic()
923
- with api_response_cache_lock:
924
- cached = api_response_cache.get(key)
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
- with api_response_cache_lock:
936
- api_response_cache[key] = (now, value)
937
- if len(api_response_cache) > 500:
938
- oldest_key = min(api_response_cache, key=lambda item: api_response_cache[item][0])
939
- api_response_cache.pop(oldest_key, None)
 
940
  return value
941
 
 
 
 
 
 
 
 
 
 
 
 
 
942
  def ready_fulltext_sources() -> set[str]:
943
  if fulltext_databases is None:
944
  return set()
945
- return {
946
- source for source in fulltext_databases.connections
947
- if fulltext_databases.tokenizer_versions.get(source) == TOKENIZER_VERSION
948
- and fulltext_databases.has_content_fts.get(source, False)
949
- and fulltext_databases.has_snippet_anchors.get(source, False)
950
- and fulltext_databases.document_counts.get(source) == source_counts.get(source)
951
- and fulltext_databases.fts_counts.get(source) == source_counts.get(source)
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
- fulltext_generation = fulltext_databases.generation if fulltext_databases is not None else 0
 
 
 
 
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 = cached_payload(
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
- discovered = discover_latest_generation(bucket)
102
- if discovered is not None:
103
- generation, discovered_paths = discovered
104
- manifest_generation = None
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).then(() => {
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())