vomebook commited on
Commit
6e056fc
·
verified ·
1 Parent(s): f8bc60d

Upload 8 files

Browse files
app/bucket_snapshot.py CHANGED
@@ -21,7 +21,7 @@ from .config import ARCHIVE_END, ARCHIVE_START, DATA_ROOT, ES_URL, INDEX_NAME
21
  from .data_loader import parsed_archive_revisions, parsed_corpus_fingerprint
22
  from .doc_store import DOC_DB
23
  from .facet_store import FACET_DB
24
- from .indexer import build_fingerprint, desired_metadata
25
  from .storage_lock import serving_lock
26
 
27
  MANIFEST_NAME = "current.json"
@@ -183,6 +183,8 @@ def _publish(bucket_dir: Path) -> None:
183
  metadata = client.get(index=INDEX_NAME, id="__meta__").get("_source", {})
184
  if metadata != desired_metadata(source_fingerprint, revisions):
185
  raise RuntimeError("cannot snapshot an index with mismatched build or source metadata")
 
 
186
  physical_name = physical_index(client)
187
  snapshot = f"bha-{int(time.time())}-{uuid.uuid4().hex[:8]}"
188
  generation = snapshot
@@ -294,6 +296,9 @@ def validate_restored_sidecars(manifest: dict[str, Any]) -> None:
294
  raise RuntimeError(f"restored docs count mismatch: expected {expected_docs}, got {docs}")
295
  if facets <= 0:
296
  raise RuntimeError(f"restored facets count is invalid: {facets}")
 
 
 
297
 
298
 
299
  def restored_sidecars_match(manifest: dict[str, Any]) -> bool:
 
21
  from .data_loader import parsed_archive_revisions, parsed_corpus_fingerprint
22
  from .doc_store import DOC_DB
23
  from .facet_store import FACET_DB
24
+ from .indexer import build_fingerprint, desired_metadata, sidecar_metadata_matches
25
  from .storage_lock import serving_lock
26
 
27
  MANIFEST_NAME = "current.json"
 
183
  metadata = client.get(index=INDEX_NAME, id="__meta__").get("_source", {})
184
  if metadata != desired_metadata(source_fingerprint, revisions):
185
  raise RuntimeError("cannot snapshot an index with mismatched build or source metadata")
186
+ if not all(sidecar_metadata_matches(path, metadata) for path in SIDECARS.values()):
187
+ raise RuntimeError("cannot snapshot mismatched SQLite sidecars")
188
  physical_name = physical_index(client)
189
  snapshot = f"bha-{int(time.time())}-{uuid.uuid4().hex[:8]}"
190
  generation = snapshot
 
296
  raise RuntimeError(f"restored docs count mismatch: expected {expected_docs}, got {docs}")
297
  if facets <= 0:
298
  raise RuntimeError(f"restored facets count is invalid: {facets}")
299
+ metadata = manifest["index_metadata"]
300
+ if not all(sidecar_metadata_matches(path, metadata) for path in SIDECARS.values()):
301
+ raise RuntimeError("restored SQLite sidecars have mismatched generation metadata")
302
 
303
 
304
  def restored_sidecars_match(manifest: dict[str, Any]) -> bool:
app/data_loader.py CHANGED
@@ -30,6 +30,7 @@ def ensure_parsed_data(progress: Callable[[int, int], None] | None = None) -> No
30
  if (target / ".git").exists():
31
  run(["git", "remote", "set-url", "origin", repo], target)
32
  run(["git", "fetch", "--depth", "1", "origin", "parsed"], target)
 
33
  run(["git", "checkout", "parsed"], target)
34
  run(["git", "reset", "--hard", "origin/parsed"], target)
35
  continue
 
30
  if (target / ".git").exists():
31
  run(["git", "remote", "set-url", "origin", repo], target)
32
  run(["git", "fetch", "--depth", "1", "origin", "parsed"], target)
33
+ run(["git", "clean", "-fdx"], target)
34
  run(["git", "checkout", "parsed"], target)
35
  run(["git", "reset", "--hard", "origin/parsed"], target)
36
  continue
app/indexer.py CHANGED
@@ -144,6 +144,15 @@ def write_sidecar_metadata(conn: sqlite3.Connection, metadata: dict) -> None:
144
  (json.dumps(metadata, ensure_ascii=False, sort_keys=True),),
145
  )
146
 
 
 
 
 
 
 
 
 
 
147
  def sidecar_metadata_matches(db_path: Path, metadata: dict) -> bool:
148
  try:
149
  with sqlite3.connect(db_path) as conn:
@@ -209,7 +218,7 @@ def incremental_build(es: Elasticsearch, source_fingerprint: str, revisions: dic
209
  try:
210
  copy_sqlite(DOC_DB, temp_doc_db)
211
  copy_sqlite(FACET_DB, temp_facet_db)
212
- if not sidecar_metadata_matches(DOC_DB, old_meta):
213
  raise IncrementalUnsafe("serving sidecar generation is unavailable")
214
  old_docs: dict[str, dict] = {}
215
  new_docs: dict[str, dict] = {}
@@ -245,6 +254,7 @@ def incremental_build(es: Elasticsearch, source_fingerprint: str, revisions: dic
245
  with connect_doc_db(temp_doc_db) as conn:
246
  write_sidecar_metadata(conn, desired_metadata(source_fingerprint, revisions))
247
  conn.commit()
 
248
  es.indices.refresh(index=build_index)
249
  with connect_doc_db(temp_doc_db) as conn:
250
  sqlite_count = int(conn.execute("SELECT COUNT(*) FROM docs").fetchone()[0])
@@ -270,7 +280,7 @@ def incremental_build(es: Elasticsearch, source_fingerprint: str, revisions: dic
270
  reset_db(temp_doc_db)
271
  reset_facet_db(temp_facet_db)
272
 
273
- def sidecars_ready() -> bool:
274
  if not DOC_DB.exists() or not FACET_DB.exists():
275
  return False
276
  try:
@@ -280,13 +290,16 @@ def sidecars_ready() -> bool:
280
  meta_count = int(conn.execute("SELECT COUNT(*) FROM build_meta").fetchone()[0])
281
  with sqlite3.connect(FACET_DB) as conn:
282
  facet_count = int(conn.execute("SELECT COUNT(*) FROM facets").fetchone()[0])
283
- return doc_count > 0 and facet_count > 0 and meta_count > 0
 
 
284
  except Exception:
285
  return False
286
 
287
  def serving_data_available(es: Elasticsearch) -> bool:
288
  try:
289
- return bool(es.indices.exists(index=INDEX_NAME)) and int(es.count(index=INDEX_NAME).get("count", 0)) > 0 and sidecars_ready()
 
290
  except Exception:
291
  return False
292
 
@@ -475,7 +488,7 @@ def _ensure_index(reset: bool = False) -> None:
475
  has_serving_data = serving_data_available(es)
476
  if es.indices.exists(index=INDEX_NAME):
477
  count = es.count(index=INDEX_NAME).get("count", 0)
478
- if not force_rebuild and count and metadata_matches(es, source_fingerprint, archive_revisions) and sidecars_ready():
479
  write_progress(status="ready", indexed=count, errors=0, elapsed_seconds=int(time.time() - started_at))
480
  return
481
  if not force_rebuild and has_serving_data:
@@ -576,6 +589,7 @@ def _ensure_index(reset: bool = False) -> None:
576
  with connect_doc_db(temp_doc_db) as conn:
577
  write_sidecar_metadata(conn, desired_metadata(source_fingerprint, archive_revisions))
578
  conn.commit()
 
579
  es.indices.refresh(index=build_index)
580
  try:
581
  es.indices.forcemerge(index=build_index, max_num_segments=1, request_timeout=3600)
 
144
  (json.dumps(metadata, ensure_ascii=False, sort_keys=True),),
145
  )
146
 
147
+ def write_facet_sidecar_metadata(db_path: Path, metadata: dict) -> None:
148
+ with sqlite3.connect(db_path) as conn:
149
+ conn.execute("CREATE TABLE IF NOT EXISTS build_meta (key TEXT PRIMARY KEY, value TEXT NOT NULL)")
150
+ conn.execute(
151
+ "INSERT OR REPLACE INTO build_meta(key, value) VALUES ('index_metadata', ?)",
152
+ (json.dumps(metadata, ensure_ascii=False, sort_keys=True),),
153
+ )
154
+ conn.commit()
155
+
156
  def sidecar_metadata_matches(db_path: Path, metadata: dict) -> bool:
157
  try:
158
  with sqlite3.connect(db_path) as conn:
 
218
  try:
219
  copy_sqlite(DOC_DB, temp_doc_db)
220
  copy_sqlite(FACET_DB, temp_facet_db)
221
+ if not sidecar_metadata_matches(DOC_DB, old_meta) or not sidecar_metadata_matches(FACET_DB, old_meta):
222
  raise IncrementalUnsafe("serving sidecar generation is unavailable")
223
  old_docs: dict[str, dict] = {}
224
  new_docs: dict[str, dict] = {}
 
254
  with connect_doc_db(temp_doc_db) as conn:
255
  write_sidecar_metadata(conn, desired_metadata(source_fingerprint, revisions))
256
  conn.commit()
257
+ write_facet_sidecar_metadata(temp_facet_db, desired_metadata(source_fingerprint, revisions))
258
  es.indices.refresh(index=build_index)
259
  with connect_doc_db(temp_doc_db) as conn:
260
  sqlite_count = int(conn.execute("SELECT COUNT(*) FROM docs").fetchone()[0])
 
280
  reset_db(temp_doc_db)
281
  reset_facet_db(temp_facet_db)
282
 
283
+ def sidecars_ready(metadata: dict | None = None) -> bool:
284
  if not DOC_DB.exists() or not FACET_DB.exists():
285
  return False
286
  try:
 
290
  meta_count = int(conn.execute("SELECT COUNT(*) FROM build_meta").fetchone()[0])
291
  with sqlite3.connect(FACET_DB) as conn:
292
  facet_count = int(conn.execute("SELECT COUNT(*) FROM facets").fetchone()[0])
293
+ docs_match = metadata is None or sidecar_metadata_matches(DOC_DB, metadata)
294
+ facets_match = metadata is None or sidecar_metadata_matches(FACET_DB, metadata)
295
+ return doc_count > 0 and facet_count > 0 and meta_count > 0 and docs_match and facets_match
296
  except Exception:
297
  return False
298
 
299
  def serving_data_available(es: Elasticsearch) -> bool:
300
  try:
301
+ metadata = es.get(index=INDEX_NAME, id="__meta__").get("_source", {})
302
+ return bool(es.indices.exists(index=INDEX_NAME)) and int(es.count(index=INDEX_NAME).get("count", 0)) > 0 and sidecars_ready(metadata)
303
  except Exception:
304
  return False
305
 
 
488
  has_serving_data = serving_data_available(es)
489
  if es.indices.exists(index=INDEX_NAME):
490
  count = es.count(index=INDEX_NAME).get("count", 0)
491
+ if not force_rebuild and count and metadata_matches(es, source_fingerprint, archive_revisions) and sidecars_ready(desired_metadata(source_fingerprint, archive_revisions)):
492
  write_progress(status="ready", indexed=count, errors=0, elapsed_seconds=int(time.time() - started_at))
493
  return
494
  if not force_rebuild and has_serving_data:
 
589
  with connect_doc_db(temp_doc_db) as conn:
590
  write_sidecar_metadata(conn, desired_metadata(source_fingerprint, archive_revisions))
591
  conn.commit()
592
+ write_facet_sidecar_metadata(temp_facet_db, desired_metadata(source_fingerprint, archive_revisions))
593
  es.indices.refresh(index=build_index)
594
  try:
595
  es.indices.forcemerge(index=build_index, max_num_segments=1, request_timeout=3600)
app/main.py CHANGED
@@ -13,7 +13,7 @@ import tempfile
13
  import threading
14
  import time
15
  from urllib.parse import quote, unquote_to_bytes, urlparse
16
- from typing import Any
17
  from zipfile import ZIP_DEFLATED, ZipFile
18
  import httpx
19
  from elasticsearch import Elasticsearch
@@ -54,6 +54,10 @@ try:
54
  )
55
  except ValueError:
56
  PROOFREAD_PROXY_NETWORKS = ()
 
 
 
 
57
  INDEX_STATUS_PATH = DATA_ROOT / "index-status"
58
  INDEX_SWITCH_STATE_PATH = DATA_ROOT / "index-switch.json"
59
  BUCKET_DIR = Path(os.environ["BHA_BUCKET_DIR"]) if os.environ.get("BHA_BUCKET_DIR") else None
@@ -74,6 +78,7 @@ app.add_middleware(
74
  es = Elasticsearch(ES_URL, request_timeout=120)
75
  source_client = httpx.AsyncClient(
76
  follow_redirects=False,
 
77
  timeout=httpx.Timeout(120, connect=10, pool=10),
78
  limits=httpx.Limits(max_connections=20, max_keepalive_connections=10, keepalive_expiry=600),
79
  )
@@ -150,20 +155,20 @@ class SearchRequest(BaseModel):
150
  exact: bool = True
151
  fulltext: bool = True
152
  source: str | None = None
153
- sources: list[str] = Field(default_factory=list)
154
- exclude_sources: list[str] = Field(default_factory=list)
155
  author: str | None = None
156
- authors: list[str] = Field(default_factory=list)
157
- exclude_authors: list[str] = Field(default_factory=list)
158
  tag: str | None = None
159
- tags: list[str] = Field(default_factory=list)
160
- exclude_tags: list[str] = Field(default_factory=list)
161
  archive_id: int | None = None
162
  publication_type: str | None = None
163
  date_from: int | None = None
164
  date_to: int | None = None
165
  date_terms: list[dict[str, int | str | None]] = Field(default_factory=list)
166
- sort: str = "relevance"
167
 
168
  class ProofreadRequest(BaseModel):
169
  doc_id: str = Field(min_length=1, max_length=512)
@@ -618,11 +623,16 @@ def index_status() -> str:
618
  def cached_status_payload(include_es: bool = False) -> dict[str, Any]:
619
  global status_response_cache
620
  now = time.monotonic()
621
- if status_response_cache and now - status_response_cache[0] < STATUS_CACHE_TTL_SECONDS:
 
 
 
 
 
622
  result = dict(status_response_cache[1])
623
  else:
624
- status = index_status()
625
- ready = index_ready() and FACET_DB.exists()
626
  result = {
627
  "ok": ready,
628
  "index_ready": ready,
@@ -725,6 +735,8 @@ def sources():
725
 
726
  def search(body: SearchRequest):
727
  status = index_status()
 
 
728
  has_data = status not in {"switching", "restoring", "failed"} and has_index_data()
729
  if not has_data:
730
  return JSONResponse({"results": [], "total": 0, "page": max(1, body.page), "page_size": body.page_size, "indexing": status != "ready", "index_status": status})
@@ -733,7 +745,7 @@ def search(body: SearchRequest):
733
  cache_key = search_cache_key(body, page, page_size)
734
  cached = search_response_cache.get(cache_key)
735
  now = time.monotonic()
736
- if cached and now - cached[0] < SEARCH_CACHE_TTL_SECONDS:
737
  return JSONResponse(dict(cached[1]))
738
  if (page - 1) * page_size >= MAX_RESULT_WINDOW:
739
  return JSONResponse({"error": "result window exceeded", "max_result_window": MAX_RESULT_WINDOW}, status_code=400)
@@ -776,7 +788,8 @@ def search(body: SearchRequest):
776
  except SearchStoreError:
777
  return JSONResponse({"error": "search storage unavailable"}, status_code=503)
778
  result = {"results": [trim_hit(hit, stored_docs.get(hit.get("_id")), body.q, body.fulltext, body.exact) for hit in hits if stored_docs.get(hit.get("_id"))], "total": total_value, "page": page, "page_size": page_size, "indexing": status != "ready", "index_status": status}
779
- search_response_cache[cache_key] = (time.monotonic(), result)
 
780
  if len(search_response_cache) > SEARCH_CACHE_MAX_ENTRIES:
781
  oldest_key = min(search_response_cache, key=lambda item: search_response_cache[item][0])
782
  search_response_cache.pop(oldest_key, None)
@@ -849,9 +862,16 @@ def proofread_rate_error(request: Request) -> JSONResponse | None:
849
  except ValueError:
850
  pass
851
  forwarded = request.headers.get("x-forwarded-for", "") if trusted_peer else ""
852
- real_ip = request.headers.get("x-real-ip", "") if trusted_peer else ""
853
  candidates = [value.strip() for value in forwarded.split(",") if value.strip()]
854
- key = real_ip.strip() if real_ip.strip() else (candidates[0] if candidates else peer)
 
 
 
 
 
 
 
 
855
  now = time.monotonic()
856
  with proofread_rate_lock:
857
  recent = [value for value in proofread_requests.get(key, []) if now - value < PROOFREAD_RATE_WINDOW_SECONDS]
@@ -867,8 +887,33 @@ def proofread_rate_error(request: Request) -> JSONResponse | None:
867
  expired.append(identity)
868
  for identity in expired:
869
  proofread_requests.pop(identity, None)
 
 
 
 
 
 
 
 
870
  return None
871
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
872
  def clean_string_list(value: Any, field: str, limit: int) -> list[str]:
873
  if not isinstance(value, list) or len(value) > limit:
874
  raise ValueError(f"{field} must be a list with at most {limit} entries")
@@ -1187,6 +1232,7 @@ async def submit_proofread(body: ProofreadRequest, request: Request):
1187
  try:
1188
  await dispatch_proofread(payload)
1189
  except Exception as exc:
 
1190
  return JSONResponse({"error": str(exc)}, status_code=502)
1191
  return {"ok": True, "status": "accepted", "tasks": 1, "auto_merge": payload["auto_merge"]}
1192
 
@@ -1474,6 +1520,8 @@ def reindex_status(request: Request):
1474
  pass
1475
  return {
1476
  "index_status": index_status(),
 
 
1477
  "archive_revisions": metadata.get("archive_revisions") or {},
1478
  "build_fingerprint": metadata.get("build_fingerprint"),
1479
  }
 
13
  import threading
14
  import time
15
  from urllib.parse import quote, unquote_to_bytes, urlparse
16
+ from typing import Any, Literal
17
  from zipfile import ZIP_DEFLATED, ZipFile
18
  import httpx
19
  from elasticsearch import Elasticsearch
 
54
  )
55
  except ValueError:
56
  PROOFREAD_PROXY_NETWORKS = ()
57
+ try:
58
+ PROOFREAD_PROXY_HOPS = max(1, int(os.environ.get("BHA_TRUST_PROXY_HOPS", "1")))
59
+ except ValueError:
60
+ PROOFREAD_PROXY_HOPS = 1
61
  INDEX_STATUS_PATH = DATA_ROOT / "index-status"
62
  INDEX_SWITCH_STATE_PATH = DATA_ROOT / "index-switch.json"
63
  BUCKET_DIR = Path(os.environ["BHA_BUCKET_DIR"]) if os.environ.get("BHA_BUCKET_DIR") else None
 
78
  es = Elasticsearch(ES_URL, request_timeout=120)
79
  source_client = httpx.AsyncClient(
80
  follow_redirects=False,
81
+ trust_env=False,
82
  timeout=httpx.Timeout(120, connect=10, pool=10),
83
  limits=httpx.Limits(max_connections=20, max_keepalive_connections=10, keepalive_expiry=600),
84
  )
 
155
  exact: bool = True
156
  fulltext: bool = True
157
  source: str | None = None
158
+ sources: list[str] = Field(default_factory=list, max_length=100)
159
+ exclude_sources: list[str] = Field(default_factory=list, max_length=100)
160
  author: str | None = None
161
+ authors: list[str] = Field(default_factory=list, max_length=100)
162
+ exclude_authors: list[str] = Field(default_factory=list, max_length=100)
163
  tag: str | None = None
164
+ tags: list[str] = Field(default_factory=list, max_length=100)
165
+ exclude_tags: list[str] = Field(default_factory=list, max_length=100)
166
  archive_id: int | None = None
167
  publication_type: str | None = None
168
  date_from: int | None = None
169
  date_to: int | None = None
170
  date_terms: list[dict[str, int | str | None]] = Field(default_factory=list)
171
+ sort: Literal["relevance", "date_desc", "date_asc", "source", "title"] = "relevance"
172
 
173
  class ProofreadRequest(BaseModel):
174
  doc_id: str = Field(min_length=1, max_length=512)
 
623
  def cached_status_payload(include_es: bool = False) -> dict[str, Any]:
624
  global status_response_cache
625
  now = time.monotonic()
626
+ current_status = index_status()
627
+ if (
628
+ status_response_cache
629
+ and now - status_response_cache[0] < STATUS_CACHE_TTL_SECONDS
630
+ and status_response_cache[1].get("index_status") == current_status
631
+ ):
632
  result = dict(status_response_cache[1])
633
  else:
634
+ status = current_status
635
+ ready = status == "ready" and index_ready() and FACET_DB.exists()
636
  result = {
637
  "ok": ready,
638
  "index_ready": ready,
 
735
 
736
  def search(body: SearchRequest):
737
  status = index_status()
738
+ if status == "unknown":
739
+ status = str(cached_status_payload().get("index_status") or status)
740
  has_data = status not in {"switching", "restoring", "failed"} and has_index_data()
741
  if not has_data:
742
  return JSONResponse({"results": [], "total": 0, "page": max(1, body.page), "page_size": body.page_size, "indexing": status != "ready", "index_status": status})
 
745
  cache_key = search_cache_key(body, page, page_size)
746
  cached = search_response_cache.get(cache_key)
747
  now = time.monotonic()
748
+ if status == "ready" and cached and now - cached[0] < SEARCH_CACHE_TTL_SECONDS:
749
  return JSONResponse(dict(cached[1]))
750
  if (page - 1) * page_size >= MAX_RESULT_WINDOW:
751
  return JSONResponse({"error": "result window exceeded", "max_result_window": MAX_RESULT_WINDOW}, status_code=400)
 
788
  except SearchStoreError:
789
  return JSONResponse({"error": "search storage unavailable"}, status_code=503)
790
  result = {"results": [trim_hit(hit, stored_docs.get(hit.get("_id")), body.q, body.fulltext, body.exact) for hit in hits if stored_docs.get(hit.get("_id"))], "total": total_value, "page": page, "page_size": page_size, "indexing": status != "ready", "index_status": status}
791
+ if status == "ready":
792
+ search_response_cache[cache_key] = (time.monotonic(), result)
793
  if len(search_response_cache) > SEARCH_CACHE_MAX_ENTRIES:
794
  oldest_key = min(search_response_cache, key=lambda item: search_response_cache[item][0])
795
  search_response_cache.pop(oldest_key, None)
 
862
  except ValueError:
863
  pass
864
  forwarded = request.headers.get("x-forwarded-for", "") if trusted_peer else ""
 
865
  candidates = [value.strip() for value in forwarded.split(",") if value.strip()]
866
+ forwarded_client = ""
867
+ if candidates and len(candidates) > PROOFREAD_PROXY_HOPS:
868
+ candidate = candidates[-PROOFREAD_PROXY_HOPS - 1]
869
+ try:
870
+ if ipaddress.ip_address(candidate):
871
+ forwarded_client = candidate
872
+ except ValueError:
873
+ pass
874
+ key = forwarded_client or peer
875
  now = time.monotonic()
876
  with proofread_rate_lock:
877
  recent = [value for value in proofread_requests.get(key, []) if now - value < PROOFREAD_RATE_WINDOW_SECONDS]
 
887
  expired.append(identity)
888
  for identity in expired:
889
  proofread_requests.pop(identity, None)
890
+ while len(proofread_requests) > 10000:
891
+ oldest = min(proofread_requests, key=lambda identity: min(proofread_requests[identity]))
892
+ proofread_requests.pop(oldest, None)
893
+ try:
894
+ request.state.proofread_rate_key = key
895
+ request.state.proofread_rate_timestamp = now
896
+ except Exception:
897
+ pass
898
  return None
899
 
900
+ def release_proofread_rate(request: Request) -> None:
901
+ try:
902
+ key = request.state.proofread_rate_key
903
+ timestamp = request.state.proofread_rate_timestamp
904
+ except Exception:
905
+ return
906
+ with proofread_rate_lock:
907
+ values = proofread_requests.get(key, [])
908
+ try:
909
+ values.remove(timestamp)
910
+ except ValueError:
911
+ return
912
+ if values:
913
+ proofread_requests[key] = values
914
+ else:
915
+ proofread_requests.pop(key, None)
916
+
917
  def clean_string_list(value: Any, field: str, limit: int) -> list[str]:
918
  if not isinstance(value, list) or len(value) > limit:
919
  raise ValueError(f"{field} must be a list with at most {limit} entries")
 
1232
  try:
1233
  await dispatch_proofread(payload)
1234
  except Exception as exc:
1235
+ release_proofread_rate(request)
1236
  return JSONResponse({"error": str(exc)}, status_code=502)
1237
  return {"ok": True, "status": "accepted", "tasks": 1, "auto_merge": payload["auto_merge"]}
1238
 
 
1520
  pass
1521
  return {
1522
  "index_status": index_status(),
1523
+ "progress": index_progress(),
1524
+ "switch_pending": INDEX_SWITCH_STATE_PATH.exists(),
1525
  "archive_revisions": metadata.get("archive_revisions") or {},
1526
  "build_fingerprint": metadata.get("build_fingerprint"),
1527
  }
app/search_store.py CHANGED
@@ -58,16 +58,16 @@ def get_search_docs(doc_ids: list[str], query: str, fulltext: bool, exact: bool)
58
  FROM requested r
59
  JOIN docs d ON d.doc_id = r.doc_id
60
  )
61
- SELECT doc_id, publication_name, publication_type, title, authors_json,
62
- tag_names_json, date_display_json, source_file_count,
63
- substr(
64
- content,
65
  CASE WHEN match_pos IS NULL THEN 1 ELSE max(1, match_pos - ?) END,
66
  CASE WHEN match_pos IS NULL THEN ? ELSE ? END
67
  ) AS content,
68
  CASE WHEN match_pos IS NULL THEN 1 ELSE max(1, match_pos - ?) END AS content_window_start,
69
- content_length
70
- FROM located
71
  ORDER BY result_order
72
  """,
73
  [
 
58
  FROM requested r
59
  JOIN docs d ON d.doc_id = r.doc_id
60
  )
61
+ SELECT doc_id, publication_name, publication_type, title, authors_json,
62
+ tag_names_json, date_display_json, source_file_count,
63
+ substr(
64
+ content,
65
  CASE WHEN match_pos IS NULL THEN 1 ELSE max(1, match_pos - ?) END,
66
  CASE WHEN match_pos IS NULL THEN ? ELSE ? END
67
  ) AS content,
68
  CASE WHEN match_pos IS NULL THEN 1 ELSE max(1, match_pos - ?) END AS content_window_start,
69
+ content_length
70
+ FROM located
71
  ORDER BY result_order
72
  """,
73
  [
app/storage_lock.py CHANGED
@@ -1,13 +1,11 @@
1
  import fcntl
2
  import errno
3
- import threading
4
  from contextlib import contextmanager
5
  from typing import Iterator
6
 
7
  from .config import DATA_ROOT
8
 
9
  LOCK_PATH = DATA_ROOT / "serving.lock"
10
- PROCESS_LOCK = threading.RLock()
11
 
12
  @contextmanager
13
  def serving_lock(exclusive: bool = False) -> Iterator[None]:
@@ -20,9 +18,7 @@ def serving_lock(exclusive: bool = False) -> Iterator[None]:
20
  lock_file.close()
21
  if exc.errno not in {errno.ENOSYS, errno.EOPNOTSUPP, errno.ENOTSUP}:
22
  raise
23
- with PROCESS_LOCK:
24
- yield
25
- return
26
  try:
27
  yield
28
  finally:
 
1
  import fcntl
2
  import errno
 
3
  from contextlib import contextmanager
4
  from typing import Iterator
5
 
6
  from .config import DATA_ROOT
7
 
8
  LOCK_PATH = DATA_ROOT / "serving.lock"
 
9
 
10
  @contextmanager
11
  def serving_lock(exclusive: bool = False) -> Iterator[None]:
 
18
  lock_file.close()
19
  if exc.errno not in {errno.ENOSYS, errno.EOPNOTSUPP, errno.ENOTSUP}:
20
  raise
21
+ raise RuntimeError("serving lock requires filesystem flock support") from exc
 
 
22
  try:
23
  yield
24
  finally: