Spaces:
Running
Running
Upload 8 files
Browse files- app/bucket_snapshot.py +6 -1
- app/data_loader.py +1 -0
- app/indexer.py +19 -5
- app/main.py +63 -15
- app/search_store.py +6 -6
- app/storage_lock.py +1 -5
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 |
-
|
|
|
|
|
|
|
| 284 |
except Exception:
|
| 285 |
return False
|
| 286 |
|
| 287 |
def serving_data_available(es: Elasticsearch) -> bool:
|
| 288 |
try:
|
| 289 |
-
|
|
|
|
| 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:
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 622 |
result = dict(status_response_cache[1])
|
| 623 |
else:
|
| 624 |
-
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 |
-
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 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 |
-
|
| 70 |
-
|
| 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 |
-
|
| 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:
|