Spaces:
Running
Running
Upload 5 files
Browse files- app/bucket_probe.py +25 -3
- app/bucket_snapshot.py +51 -43
- app/data_loader.py +26 -0
- app/indexer.py +29 -7
- app/main.py +29 -6
app/bucket_probe.py
CHANGED
|
@@ -3,14 +3,24 @@
|
|
| 3 |
from __future__ import annotations
|
| 4 |
|
| 5 |
import sys
|
|
|
|
| 6 |
import uuid
|
| 7 |
from pathlib import Path
|
| 8 |
|
| 9 |
from elasticsearch import Elasticsearch
|
| 10 |
|
| 11 |
-
from .config import ES_URL
|
| 12 |
|
| 13 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 14 |
|
| 15 |
|
| 16 |
def main() -> int:
|
|
@@ -22,17 +32,27 @@ def main() -> int:
|
|
| 22 |
repository = "bha_bucket_probe"
|
| 23 |
index = f"bha-bucket-probe-{uuid.uuid4().hex[:12]}"
|
| 24 |
snapshot = f"probe-{uuid.uuid4().hex[:12]}"
|
|
|
|
| 25 |
client = Elasticsearch(ES_URL, request_timeout=120)
|
| 26 |
try:
|
|
|
|
| 27 |
client.indices.create(index=index)
|
| 28 |
client.index(index=index, id="1", document={"probe": "bucket"}, refresh="wait_for")
|
| 29 |
client.snapshot.create_repository(
|
| 30 |
name=repository,
|
| 31 |
-
repository={"type": "fs", "settings": {"location": str(
|
| 32 |
verify=True,
|
| 33 |
)
|
| 34 |
client.snapshot.create(repository=repository, snapshot=snapshot, indices=index, wait_for_completion=True)
|
|
|
|
| 35 |
client.indices.delete(index=index)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 36 |
client.snapshot.restore(repository=repository, snapshot=snapshot, indices=index, wait_for_completion=True)
|
| 37 |
if int(client.count(index=index)["count"]) != 1:
|
| 38 |
raise RuntimeError("restored probe index has the wrong document count")
|
|
@@ -40,6 +60,8 @@ def main() -> int:
|
|
| 40 |
return 0
|
| 41 |
finally:
|
| 42 |
client.indices.delete(index=index, ignore_unavailable=True)
|
|
|
|
|
|
|
| 43 |
|
| 44 |
|
| 45 |
if __name__ == "__main__":
|
|
|
|
| 3 |
from __future__ import annotations
|
| 4 |
|
| 5 |
import sys
|
| 6 |
+
import shutil
|
| 7 |
import uuid
|
| 8 |
from pathlib import Path
|
| 9 |
|
| 10 |
from elasticsearch import Elasticsearch
|
| 11 |
|
| 12 |
+
from .config import DATA_ROOT, ES_URL
|
| 13 |
|
| 14 |
+
LOCAL_REPOSITORY = DATA_ROOT / "es-repositories" / "probe"
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
def clear_directory(path: Path) -> None:
|
| 18 |
+
path.mkdir(parents=True, exist_ok=True)
|
| 19 |
+
for child in path.iterdir():
|
| 20 |
+
if child.is_dir():
|
| 21 |
+
shutil.rmtree(child)
|
| 22 |
+
else:
|
| 23 |
+
child.unlink()
|
| 24 |
|
| 25 |
|
| 26 |
def main() -> int:
|
|
|
|
| 32 |
repository = "bha_bucket_probe"
|
| 33 |
index = f"bha-bucket-probe-{uuid.uuid4().hex[:12]}"
|
| 34 |
snapshot = f"probe-{uuid.uuid4().hex[:12]}"
|
| 35 |
+
bucket_copy = bucket_dir / "probe" / snapshot
|
| 36 |
client = Elasticsearch(ES_URL, request_timeout=120)
|
| 37 |
try:
|
| 38 |
+
clear_directory(LOCAL_REPOSITORY)
|
| 39 |
client.indices.create(index=index)
|
| 40 |
client.index(index=index, id="1", document={"probe": "bucket"}, refresh="wait_for")
|
| 41 |
client.snapshot.create_repository(
|
| 42 |
name=repository,
|
| 43 |
+
repository={"type": "fs", "settings": {"location": str(LOCAL_REPOSITORY)}},
|
| 44 |
verify=True,
|
| 45 |
)
|
| 46 |
client.snapshot.create(repository=repository, snapshot=snapshot, indices=index, wait_for_completion=True)
|
| 47 |
+
shutil.copytree(LOCAL_REPOSITORY, bucket_copy)
|
| 48 |
client.indices.delete(index=index)
|
| 49 |
+
clear_directory(LOCAL_REPOSITORY)
|
| 50 |
+
shutil.copytree(bucket_copy, LOCAL_REPOSITORY, dirs_exist_ok=True)
|
| 51 |
+
client.snapshot.create_repository(
|
| 52 |
+
name=repository,
|
| 53 |
+
repository={"type": "fs", "settings": {"location": str(LOCAL_REPOSITORY)}},
|
| 54 |
+
verify=True,
|
| 55 |
+
)
|
| 56 |
client.snapshot.restore(repository=repository, snapshot=snapshot, indices=index, wait_for_completion=True)
|
| 57 |
if int(client.count(index=index)["count"]) != 1:
|
| 58 |
raise RuntimeError("restored probe index has the wrong document count")
|
|
|
|
| 60 |
return 0
|
| 61 |
finally:
|
| 62 |
client.indices.delete(index=index, ignore_unavailable=True)
|
| 63 |
+
shutil.rmtree(bucket_copy, ignore_errors=True)
|
| 64 |
+
clear_directory(LOCAL_REPOSITORY)
|
| 65 |
|
| 66 |
|
| 67 |
if __name__ == "__main__":
|
app/bucket_snapshot.py
CHANGED
|
@@ -8,7 +8,6 @@ import os
|
|
| 8 |
import re
|
| 9 |
import shutil
|
| 10 |
import sqlite3
|
| 11 |
-
import subprocess
|
| 12 |
import sys
|
| 13 |
import tempfile
|
| 14 |
import time
|
|
@@ -18,39 +17,32 @@ from typing import Any
|
|
| 18 |
|
| 19 |
from elasticsearch import Elasticsearch
|
| 20 |
|
| 21 |
-
from .config import
|
|
|
|
| 22 |
from .doc_store import DOC_DB
|
| 23 |
from .facet_store import FACET_DB
|
|
|
|
| 24 |
from .storage_lock import serving_lock
|
| 25 |
|
| 26 |
MANIFEST_NAME = "current.json"
|
| 27 |
REPOSITORY = "bha_bucket"
|
| 28 |
-
# Keep production repository metadata separate from the disposable probe repository.
|
| 29 |
-
SNAPSHOT_ROOT = "snapshots/production"
|
| 30 |
GENERATIONS_ROOT = "generations"
|
|
|
|
| 31 |
SIDECARS = {"docs": DOC_DB, "facets": FACET_DB}
|
| 32 |
SNAPSHOT_NAME_RE = re.compile(r"^bha-[a-z0-9-]+$")
|
| 33 |
|
| 34 |
|
| 35 |
-
def
|
| 36 |
-
return
|
| 37 |
|
| 38 |
|
| 39 |
-
def
|
| 40 |
-
|
| 41 |
-
for
|
| 42 |
-
if
|
| 43 |
-
|
| 44 |
-
result = subprocess.run(command, check=True, capture_output=True, text=True)
|
| 45 |
-
revision = result.stdout.split(maxsplit=1)[0]
|
| 46 |
else:
|
| 47 |
-
|
| 48 |
-
result = subprocess.run(["git", "rev-parse", "HEAD"], cwd=repository_path, check=True, capture_output=True, text=True)
|
| 49 |
-
revision = result.stdout.strip()
|
| 50 |
-
if not re.fullmatch(r"[0-9a-f]{40}", revision):
|
| 51 |
-
raise RuntimeError(f"invalid parsed revision for archive {archive_id}")
|
| 52 |
-
revisions.append(f"{archive_id}:{revision}")
|
| 53 |
-
return hashlib.sha256("\n".join(revisions).encode()).hexdigest()
|
| 54 |
|
| 55 |
|
| 56 |
def sha256(path: Path) -> str:
|
|
@@ -65,10 +57,10 @@ def file_metadata(path: Path) -> dict[str, Any]:
|
|
| 65 |
return {"filename": path.name, "bytes": path.stat().st_size, "sha256": sha256(path)}
|
| 66 |
|
| 67 |
|
| 68 |
-
def repository(client: Elasticsearch
|
| 69 |
client.snapshot.create_repository(
|
| 70 |
name=REPOSITORY,
|
| 71 |
-
repository={"type": "fs", "settings": {"location": str(
|
| 72 |
verify=True,
|
| 73 |
)
|
| 74 |
|
|
@@ -82,12 +74,15 @@ def read_manifest(bucket_dir: Path) -> dict[str, Any] | None:
|
|
| 82 |
data = json.loads(manifest_path(bucket_dir).read_text(encoding="utf-8"))
|
| 83 |
except (OSError, json.JSONDecodeError):
|
| 84 |
return None
|
| 85 |
-
if not isinstance(data, dict) or data.get("schema") !=
|
|
|
|
|
|
|
|
|
|
| 86 |
return None
|
| 87 |
snapshot = data.get("snapshot")
|
| 88 |
generation = data.get("generation")
|
| 89 |
physical_index = data.get("physical_index")
|
| 90 |
-
source_fingerprint =
|
| 91 |
sidecars = data.get("sidecars")
|
| 92 |
if not isinstance(snapshot, str) or not SNAPSHOT_NAME_RE.fullmatch(snapshot):
|
| 93 |
return None
|
|
@@ -97,6 +92,8 @@ def read_manifest(bucket_dir: Path) -> dict[str, Any] | None:
|
|
| 97 |
return None
|
| 98 |
if not isinstance(source_fingerprint, str) or not re.fullmatch(r"[0-9a-f]{64}", source_fingerprint):
|
| 99 |
return None
|
|
|
|
|
|
|
| 100 |
if not isinstance(sidecars, dict) or set(sidecars) != set(SIDECARS):
|
| 101 |
return None
|
| 102 |
for key, target in SIDECARS.items():
|
|
@@ -125,14 +122,15 @@ def publish(bucket_dir: Path) -> None:
|
|
| 125 |
if not all(path.is_file() for path in SIDECARS.values()):
|
| 126 |
raise RuntimeError("cannot snapshot missing SQLite sidecars")
|
| 127 |
client = Elasticsearch(ES_URL, request_timeout=3600)
|
|
|
|
| 128 |
metadata = client.get(index=INDEX_NAME, id="__meta__").get("_source", {})
|
| 129 |
-
if metadata !=
|
| 130 |
-
raise RuntimeError("cannot snapshot an index with mismatched metadata")
|
| 131 |
-
source_fingerprint = corpus_fingerprint(remote=False)
|
| 132 |
physical_name = physical_index(client)
|
| 133 |
snapshot = f"bha-{int(time.time())}-{uuid.uuid4().hex[:8]}"
|
| 134 |
generation = snapshot
|
| 135 |
-
|
|
|
|
| 136 |
started = time.monotonic()
|
| 137 |
client.snapshot.create(
|
| 138 |
repository=REPOSITORY,
|
|
@@ -143,6 +141,7 @@ def publish(bucket_dir: Path) -> None:
|
|
| 143 |
)
|
| 144 |
generation_dir = bucket_dir / GENERATIONS_ROOT / generation
|
| 145 |
generation_dir.mkdir(parents=True, exist_ok=False)
|
|
|
|
| 146 |
sidecars: dict[str, dict[str, Any]] = {}
|
| 147 |
with tempfile.TemporaryDirectory(dir=DATA_ROOT, prefix="bha-snapshot-") as temporary_dir:
|
| 148 |
for key, source in SIDECARS.items():
|
|
@@ -152,11 +151,9 @@ def publish(bucket_dir: Path) -> None:
|
|
| 152 |
shutil.copyfile(local_copy, destination)
|
| 153 |
sidecars[key] = file_metadata(destination)
|
| 154 |
payload = {
|
| 155 |
-
"schema":
|
| 156 |
"index_name": INDEX_NAME,
|
| 157 |
-
"
|
| 158 |
-
"index_metadata": expected_metadata(),
|
| 159 |
-
"source_fingerprint": source_fingerprint,
|
| 160 |
"physical_index": physical_name,
|
| 161 |
"snapshot": snapshot,
|
| 162 |
"generation": generation,
|
|
@@ -171,13 +168,12 @@ def publish(bucket_dir: Path) -> None:
|
|
| 171 |
for path in (bucket_dir / GENERATIONS_ROOT).iterdir():
|
| 172 |
if path.is_dir() and path.name != generation and SNAPSHOT_NAME_RE.fullmatch(path.name):
|
| 173 |
shutil.rmtree(path)
|
| 174 |
-
|
| 175 |
-
|
| 176 |
-
|
| 177 |
-
if isinstance(previous_name, str) and previous_name != snapshot:
|
| 178 |
-
client.snapshot.delete(repository=REPOSITORY, snapshot=previous_name)
|
| 179 |
except Exception as exc:
|
| 180 |
print(f"bha_bucket_snapshot=cleanup_failed error={type(exc).__name__}", flush=True)
|
|
|
|
| 181 |
print(f"bha_bucket_snapshot=published snapshot={snapshot} elapsed_seconds={time.monotonic() - started:.2f}", flush=True)
|
| 182 |
|
| 183 |
|
|
@@ -238,19 +234,23 @@ def restore(bucket_dir: Path) -> bool:
|
|
| 238 |
print("bha_bucket_snapshot=unavailable reason=missing_or_invalid_manifest", flush=True)
|
| 239 |
return False
|
| 240 |
try:
|
| 241 |
-
if manifest["source_fingerprint"] !=
|
| 242 |
print("bha_bucket_snapshot=unavailable reason=source_fingerprint_mismatch", flush=True)
|
| 243 |
return False
|
| 244 |
except Exception as exc:
|
| 245 |
-
print(f"bha_bucket_snapshot=
|
| 246 |
-
return False
|
| 247 |
client = Elasticsearch(ES_URL, request_timeout=3600)
|
| 248 |
if client.indices.exists(index=INDEX_NAME):
|
| 249 |
print("bha_bucket_snapshot=skipped reason=index_exists", flush=True)
|
| 250 |
return False
|
| 251 |
started = time.monotonic()
|
| 252 |
try:
|
| 253 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 254 |
client.snapshot.restore(
|
| 255 |
repository=REPOSITORY,
|
| 256 |
snapshot=str(manifest["snapshot"]),
|
|
@@ -259,17 +259,25 @@ def restore(bucket_dir: Path) -> bool:
|
|
| 259 |
include_aliases=True,
|
| 260 |
wait_for_completion=True,
|
| 261 |
)
|
|
|
|
|
|
|
| 262 |
metadata = client.get(index=INDEX_NAME, id="__meta__").get("_source", {})
|
| 263 |
count = int(client.count(index=INDEX_NAME, query={"exists": {"field": "doc_id"}})["count"])
|
| 264 |
-
if metadata !=
|
| 265 |
raise RuntimeError("restored Elasticsearch snapshot failed validation")
|
| 266 |
restore_sidecars(bucket_dir, manifest)
|
| 267 |
if not restored_sidecars_match(manifest):
|
| 268 |
raise RuntimeError("restored SQLite sidecars failed validation")
|
| 269 |
except Exception as exc:
|
| 270 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 271 |
print(f"bha_bucket_snapshot=unavailable reason={type(exc).__name__}", flush=True)
|
| 272 |
return False
|
|
|
|
| 273 |
print(f"bha_bucket_snapshot=restored snapshot={manifest['snapshot']} elapsed_seconds={time.monotonic() - started:.2f}", flush=True)
|
| 274 |
return True
|
| 275 |
|
|
|
|
| 8 |
import re
|
| 9 |
import shutil
|
| 10 |
import sqlite3
|
|
|
|
| 11 |
import sys
|
| 12 |
import tempfile
|
| 13 |
import time
|
|
|
|
| 17 |
|
| 18 |
from elasticsearch import Elasticsearch
|
| 19 |
|
| 20 |
+
from .config import DATA_ROOT, ES_URL, INDEX_NAME
|
| 21 |
+
from .data_loader import 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"
|
| 28 |
REPOSITORY = "bha_bucket"
|
|
|
|
|
|
|
| 29 |
GENERATIONS_ROOT = "generations"
|
| 30 |
+
REPOSITORY_DIRNAME = "elasticsearch"
|
| 31 |
SIDECARS = {"docs": DOC_DB, "facets": FACET_DB}
|
| 32 |
SNAPSHOT_NAME_RE = re.compile(r"^bha-[a-z0-9-]+$")
|
| 33 |
|
| 34 |
|
| 35 |
+
def local_repository() -> Path:
|
| 36 |
+
return DATA_ROOT / "es-repositories" / "production"
|
| 37 |
|
| 38 |
|
| 39 |
+
def clear_directory(path: Path) -> None:
|
| 40 |
+
path.mkdir(parents=True, exist_ok=True)
|
| 41 |
+
for child in path.iterdir():
|
| 42 |
+
if child.is_dir():
|
| 43 |
+
shutil.rmtree(child)
|
|
|
|
|
|
|
| 44 |
else:
|
| 45 |
+
child.unlink()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 46 |
|
| 47 |
|
| 48 |
def sha256(path: Path) -> str:
|
|
|
|
| 57 |
return {"filename": path.name, "bytes": path.stat().st_size, "sha256": sha256(path)}
|
| 58 |
|
| 59 |
|
| 60 |
+
def repository(client: Elasticsearch) -> None:
|
| 61 |
client.snapshot.create_repository(
|
| 62 |
name=REPOSITORY,
|
| 63 |
+
repository={"type": "fs", "settings": {"location": str(local_repository())}},
|
| 64 |
verify=True,
|
| 65 |
)
|
| 66 |
|
|
|
|
| 74 |
data = json.loads(manifest_path(bucket_dir).read_text(encoding="utf-8"))
|
| 75 |
except (OSError, json.JSONDecodeError):
|
| 76 |
return None
|
| 77 |
+
if not isinstance(data, dict) or data.get("schema") != 2:
|
| 78 |
+
return None
|
| 79 |
+
index_metadata = data.get("index_metadata")
|
| 80 |
+
if not isinstance(index_metadata, dict) or index_metadata.get("build_fingerprint") != build_fingerprint():
|
| 81 |
return None
|
| 82 |
snapshot = data.get("snapshot")
|
| 83 |
generation = data.get("generation")
|
| 84 |
physical_index = data.get("physical_index")
|
| 85 |
+
source_fingerprint = index_metadata.get("source_fingerprint")
|
| 86 |
sidecars = data.get("sidecars")
|
| 87 |
if not isinstance(snapshot, str) or not SNAPSHOT_NAME_RE.fullmatch(snapshot):
|
| 88 |
return None
|
|
|
|
| 92 |
return None
|
| 93 |
if not isinstance(source_fingerprint, str) or not re.fullmatch(r"[0-9a-f]{64}", source_fingerprint):
|
| 94 |
return None
|
| 95 |
+
if index_metadata != desired_metadata(source_fingerprint):
|
| 96 |
+
return None
|
| 97 |
if not isinstance(sidecars, dict) or set(sidecars) != set(SIDECARS):
|
| 98 |
return None
|
| 99 |
for key, target in SIDECARS.items():
|
|
|
|
| 122 |
if not all(path.is_file() for path in SIDECARS.values()):
|
| 123 |
raise RuntimeError("cannot snapshot missing SQLite sidecars")
|
| 124 |
client = Elasticsearch(ES_URL, request_timeout=3600)
|
| 125 |
+
source_fingerprint = parsed_corpus_fingerprint()
|
| 126 |
metadata = client.get(index=INDEX_NAME, id="__meta__").get("_source", {})
|
| 127 |
+
if metadata != desired_metadata(source_fingerprint):
|
| 128 |
+
raise RuntimeError("cannot snapshot an index with mismatched build or source metadata")
|
|
|
|
| 129 |
physical_name = physical_index(client)
|
| 130 |
snapshot = f"bha-{int(time.time())}-{uuid.uuid4().hex[:8]}"
|
| 131 |
generation = snapshot
|
| 132 |
+
clear_directory(local_repository())
|
| 133 |
+
repository(client)
|
| 134 |
started = time.monotonic()
|
| 135 |
client.snapshot.create(
|
| 136 |
repository=REPOSITORY,
|
|
|
|
| 141 |
)
|
| 142 |
generation_dir = bucket_dir / GENERATIONS_ROOT / generation
|
| 143 |
generation_dir.mkdir(parents=True, exist_ok=False)
|
| 144 |
+
shutil.copytree(local_repository(), generation_dir / REPOSITORY_DIRNAME)
|
| 145 |
sidecars: dict[str, dict[str, Any]] = {}
|
| 146 |
with tempfile.TemporaryDirectory(dir=DATA_ROOT, prefix="bha-snapshot-") as temporary_dir:
|
| 147 |
for key, source in SIDECARS.items():
|
|
|
|
| 151 |
shutil.copyfile(local_copy, destination)
|
| 152 |
sidecars[key] = file_metadata(destination)
|
| 153 |
payload = {
|
| 154 |
+
"schema": 2,
|
| 155 |
"index_name": INDEX_NAME,
|
| 156 |
+
"index_metadata": metadata,
|
|
|
|
|
|
|
| 157 |
"physical_index": physical_name,
|
| 158 |
"snapshot": snapshot,
|
| 159 |
"generation": generation,
|
|
|
|
| 168 |
for path in (bucket_dir / GENERATIONS_ROOT).iterdir():
|
| 169 |
if path.is_dir() and path.name != generation and SNAPSHOT_NAME_RE.fullmatch(path.name):
|
| 170 |
shutil.rmtree(path)
|
| 171 |
+
legacy_repository = bucket_dir / "snapshots"
|
| 172 |
+
if legacy_repository.exists():
|
| 173 |
+
shutil.rmtree(legacy_repository)
|
|
|
|
|
|
|
| 174 |
except Exception as exc:
|
| 175 |
print(f"bha_bucket_snapshot=cleanup_failed error={type(exc).__name__}", flush=True)
|
| 176 |
+
clear_directory(local_repository())
|
| 177 |
print(f"bha_bucket_snapshot=published snapshot={snapshot} elapsed_seconds={time.monotonic() - started:.2f}", flush=True)
|
| 178 |
|
| 179 |
|
|
|
|
| 234 |
print("bha_bucket_snapshot=unavailable reason=missing_or_invalid_manifest", flush=True)
|
| 235 |
return False
|
| 236 |
try:
|
| 237 |
+
if manifest["index_metadata"]["source_fingerprint"] != parsed_corpus_fingerprint(remote=True):
|
| 238 |
print("bha_bucket_snapshot=unavailable reason=source_fingerprint_mismatch", flush=True)
|
| 239 |
return False
|
| 240 |
except Exception as exc:
|
| 241 |
+
print(f"bha_bucket_snapshot=source_fingerprint_unverified reason={type(exc).__name__}", flush=True)
|
|
|
|
| 242 |
client = Elasticsearch(ES_URL, request_timeout=3600)
|
| 243 |
if client.indices.exists(index=INDEX_NAME):
|
| 244 |
print("bha_bucket_snapshot=skipped reason=index_exists", flush=True)
|
| 245 |
return False
|
| 246 |
started = time.monotonic()
|
| 247 |
try:
|
| 248 |
+
clear_directory(local_repository())
|
| 249 |
+
repository_source = bucket_dir / GENERATIONS_ROOT / str(manifest["generation"]) / REPOSITORY_DIRNAME
|
| 250 |
+
if not repository_source.is_dir():
|
| 251 |
+
raise RuntimeError("bucket snapshot repository is missing")
|
| 252 |
+
shutil.copytree(repository_source, local_repository(), dirs_exist_ok=True)
|
| 253 |
+
repository(client)
|
| 254 |
client.snapshot.restore(
|
| 255 |
repository=REPOSITORY,
|
| 256 |
snapshot=str(manifest["snapshot"]),
|
|
|
|
| 259 |
include_aliases=True,
|
| 260 |
wait_for_completion=True,
|
| 261 |
)
|
| 262 |
+
if not client.indices.exists_alias(name=INDEX_NAME):
|
| 263 |
+
client.indices.put_alias(index=str(manifest["physical_index"]), name=INDEX_NAME)
|
| 264 |
metadata = client.get(index=INDEX_NAME, id="__meta__").get("_source", {})
|
| 265 |
count = int(client.count(index=INDEX_NAME, query={"exists": {"field": "doc_id"}})["count"])
|
| 266 |
+
if metadata != manifest["index_metadata"] or count != int(manifest["document_count"]):
|
| 267 |
raise RuntimeError("restored Elasticsearch snapshot failed validation")
|
| 268 |
restore_sidecars(bucket_dir, manifest)
|
| 269 |
if not restored_sidecars_match(manifest):
|
| 270 |
raise RuntimeError("restored SQLite sidecars failed validation")
|
| 271 |
except Exception as exc:
|
| 272 |
+
for target in (INDEX_NAME, str(manifest["physical_index"])):
|
| 273 |
+
try:
|
| 274 |
+
client.indices.delete(index=target, ignore_unavailable=True)
|
| 275 |
+
except Exception:
|
| 276 |
+
pass
|
| 277 |
+
clear_directory(local_repository())
|
| 278 |
print(f"bha_bucket_snapshot=unavailable reason={type(exc).__name__}", flush=True)
|
| 279 |
return False
|
| 280 |
+
clear_directory(local_repository())
|
| 281 |
print(f"bha_bucket_snapshot=restored snapshot={manifest['snapshot']} elapsed_seconds={time.monotonic() - started:.2f}", flush=True)
|
| 282 |
return True
|
| 283 |
|
app/data_loader.py
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
|
|
| 1 |
import json
|
| 2 |
import re
|
| 3 |
import shutil
|
|
@@ -35,6 +36,31 @@ def ensure_parsed_data(progress: Callable[[int, int], None] | None = None) -> No
|
|
| 35 |
shutil.rmtree(target)
|
| 36 |
run(["git", "clone", "--depth", "1", "--branch", "parsed", repo, str(target)])
|
| 37 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 38 |
def read_json(path: Path) -> Any:
|
| 39 |
try:
|
| 40 |
return json.loads(path.read_text(encoding="utf-8"))
|
|
|
|
| 1 |
+
import hashlib
|
| 2 |
import json
|
| 3 |
import re
|
| 4 |
import shutil
|
|
|
|
| 36 |
shutil.rmtree(target)
|
| 37 |
run(["git", "clone", "--depth", "1", "--branch", "parsed", repo, str(target)])
|
| 38 |
|
| 39 |
+
def parsed_corpus_fingerprint(remote: bool = False) -> str:
|
| 40 |
+
revisions: list[str] = []
|
| 41 |
+
for archive_id in range(ARCHIVE_START, ARCHIVE_END + 1):
|
| 42 |
+
if remote:
|
| 43 |
+
result = subprocess.run(
|
| 44 |
+
["git", "ls-remote", f"{REPO_PREFIX}/banned-historical-archives{archive_id}.git", "refs/heads/parsed"],
|
| 45 |
+
check=True,
|
| 46 |
+
capture_output=True,
|
| 47 |
+
text=True,
|
| 48 |
+
)
|
| 49 |
+
revision = result.stdout.split(maxsplit=1)[0]
|
| 50 |
+
else:
|
| 51 |
+
result = subprocess.run(
|
| 52 |
+
["git", "rev-parse", "HEAD"],
|
| 53 |
+
cwd=PARSED_ROOT / f"archives{archive_id}",
|
| 54 |
+
check=True,
|
| 55 |
+
capture_output=True,
|
| 56 |
+
text=True,
|
| 57 |
+
)
|
| 58 |
+
revision = result.stdout.strip()
|
| 59 |
+
if not re.fullmatch(r"[0-9a-f]{40}", revision):
|
| 60 |
+
raise RuntimeError(f"invalid parsed revision for archive {archive_id}")
|
| 61 |
+
revisions.append(f"{archive_id}:{revision}")
|
| 62 |
+
return hashlib.sha256("\n".join(revisions).encode()).hexdigest()
|
| 63 |
+
|
| 64 |
def read_json(path: Path) -> Any:
|
| 65 |
try:
|
| 66 |
return json.loads(path.read_text(encoding="utf-8"))
|
app/indexer.py
CHANGED
|
@@ -1,5 +1,6 @@
|
|
| 1 |
import argparse
|
| 2 |
import fcntl
|
|
|
|
| 3 |
import json
|
| 4 |
import os
|
| 5 |
import sqlite3
|
|
@@ -11,7 +12,7 @@ from typing import IO
|
|
| 11 |
from elasticsearch import Elasticsearch, helpers
|
| 12 |
from elasticsearch.exceptions import NotFoundError
|
| 13 |
from .config import DATA_ROOT, ARCHIVE_END, ARCHIVE_START, ES_URL, INDEX_NAME, INDEX_VERSION, RESET_INDEX
|
| 14 |
-
from .data_loader import ensure_parsed_data, iter_documents
|
| 15 |
from .doc_store import DOC_DB, connect as connect_doc_db, init_db as init_doc_db, reset_db, write_docs_to_conn
|
| 16 |
from .facet_store import FACET_DB, connect as connect_facet_db, init_db as init_facet_db, reset_db as reset_facet_db, write_facets
|
| 17 |
from .storage_lock import serving_lock
|
|
@@ -93,16 +94,36 @@ def index_mapping() -> dict:
|
|
| 93 |
},
|
| 94 |
}
|
| 95 |
|
| 96 |
-
def
|
| 97 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 98 |
|
| 99 |
-
def
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 100 |
try:
|
| 101 |
data = es.get(index=INDEX_NAME, id="__meta__")
|
| 102 |
except Exception:
|
| 103 |
return False
|
| 104 |
source = data.get("_source", {})
|
| 105 |
-
return
|
| 106 |
|
| 107 |
def sidecars_ready() -> bool:
|
| 108 |
if not DOC_DB.exists() or not FACET_DB.exists():
|
|
@@ -282,13 +303,14 @@ def _ensure_index(reset: bool = False) -> None:
|
|
| 282 |
archive_total=total,
|
| 283 |
elapsed_seconds=int(time.time() - started_at),
|
| 284 |
))
|
|
|
|
| 285 |
parsed_elapsed_seconds = round(time.time() - parsed_started_at, 2)
|
| 286 |
print(f"index_phase=parsed_data elapsed_seconds={parsed_elapsed_seconds}")
|
| 287 |
force_rebuild = reset or RESET_INDEX
|
| 288 |
has_serving_data = serving_data_available(es)
|
| 289 |
if es.indices.exists(index=INDEX_NAME):
|
| 290 |
count = es.count(index=INDEX_NAME).get("count", 0)
|
| 291 |
-
if not force_rebuild and count and metadata_matches(es) and sidecars_ready():
|
| 292 |
print(f"{INDEX_NAME} already has {count} documents")
|
| 293 |
write_progress(status="ready", indexed=count, errors=0, elapsed_seconds=int(time.time() - started_at))
|
| 294 |
return
|
|
@@ -370,7 +392,7 @@ def _ensure_index(reset: bool = False) -> None:
|
|
| 370 |
write_facets(facet_counters, temp_facet_db)
|
| 371 |
facets_elapsed_seconds = round(time.time() - facets_started_at, 2)
|
| 372 |
print(f"index_phase=facets elapsed_seconds={facets_elapsed_seconds}")
|
| 373 |
-
es.index(index=build_index, id="__meta__", document=desired_metadata())
|
| 374 |
refresh_started_at = time.time()
|
| 375 |
es.indices.refresh(index=build_index)
|
| 376 |
refresh_elapsed_seconds = round(time.time() - refresh_started_at, 2)
|
|
|
|
| 1 |
import argparse
|
| 2 |
import fcntl
|
| 3 |
+
import hashlib
|
| 4 |
import json
|
| 5 |
import os
|
| 6 |
import sqlite3
|
|
|
|
| 12 |
from elasticsearch import Elasticsearch, helpers
|
| 13 |
from elasticsearch.exceptions import NotFoundError
|
| 14 |
from .config import DATA_ROOT, ARCHIVE_END, ARCHIVE_START, ES_URL, INDEX_NAME, INDEX_VERSION, RESET_INDEX
|
| 15 |
+
from .data_loader import ensure_parsed_data, iter_documents, parsed_corpus_fingerprint
|
| 16 |
from .doc_store import DOC_DB, connect as connect_doc_db, init_db as init_doc_db, reset_db, write_docs_to_conn
|
| 17 |
from .facet_store import FACET_DB, connect as connect_facet_db, init_db as init_facet_db, reset_db as reset_facet_db, write_facets
|
| 18 |
from .storage_lock import serving_lock
|
|
|
|
| 94 |
},
|
| 95 |
}
|
| 96 |
|
| 97 |
+
def build_fingerprint() -> str:
|
| 98 |
+
digest = hashlib.sha256(json.dumps(index_mapping(), sort_keys=True, separators=(",", ":")).encode())
|
| 99 |
+
for module_path in (
|
| 100 |
+
Path(__file__),
|
| 101 |
+
Path(__file__).with_name("data_loader.py"),
|
| 102 |
+
Path(__file__).with_name("doc_store.py"),
|
| 103 |
+
Path(__file__).with_name("facet_store.py"),
|
| 104 |
+
):
|
| 105 |
+
digest.update(module_path.name.encode())
|
| 106 |
+
digest.update(module_path.read_bytes())
|
| 107 |
+
return digest.hexdigest()
|
| 108 |
|
| 109 |
+
def desired_metadata(source_fingerprint: str | None = None) -> dict:
|
| 110 |
+
metadata = {
|
| 111 |
+
"version": INDEX_VERSION,
|
| 112 |
+
"archive_start": ARCHIVE_START,
|
| 113 |
+
"archive_end": ARCHIVE_END,
|
| 114 |
+
"build_fingerprint": build_fingerprint(),
|
| 115 |
+
}
|
| 116 |
+
if source_fingerprint is not None:
|
| 117 |
+
metadata["source_fingerprint"] = source_fingerprint
|
| 118 |
+
return metadata
|
| 119 |
+
|
| 120 |
+
def metadata_matches(es: Elasticsearch, source_fingerprint: str) -> bool:
|
| 121 |
try:
|
| 122 |
data = es.get(index=INDEX_NAME, id="__meta__")
|
| 123 |
except Exception:
|
| 124 |
return False
|
| 125 |
source = data.get("_source", {})
|
| 126 |
+
return source == desired_metadata(source_fingerprint)
|
| 127 |
|
| 128 |
def sidecars_ready() -> bool:
|
| 129 |
if not DOC_DB.exists() or not FACET_DB.exists():
|
|
|
|
| 303 |
archive_total=total,
|
| 304 |
elapsed_seconds=int(time.time() - started_at),
|
| 305 |
))
|
| 306 |
+
source_fingerprint = parsed_corpus_fingerprint()
|
| 307 |
parsed_elapsed_seconds = round(time.time() - parsed_started_at, 2)
|
| 308 |
print(f"index_phase=parsed_data elapsed_seconds={parsed_elapsed_seconds}")
|
| 309 |
force_rebuild = reset or RESET_INDEX
|
| 310 |
has_serving_data = serving_data_available(es)
|
| 311 |
if es.indices.exists(index=INDEX_NAME):
|
| 312 |
count = es.count(index=INDEX_NAME).get("count", 0)
|
| 313 |
+
if not force_rebuild and count and metadata_matches(es, source_fingerprint) and sidecars_ready():
|
| 314 |
print(f"{INDEX_NAME} already has {count} documents")
|
| 315 |
write_progress(status="ready", indexed=count, errors=0, elapsed_seconds=int(time.time() - started_at))
|
| 316 |
return
|
|
|
|
| 392 |
write_facets(facet_counters, temp_facet_db)
|
| 393 |
facets_elapsed_seconds = round(time.time() - facets_started_at, 2)
|
| 394 |
print(f"index_phase=facets elapsed_seconds={facets_elapsed_seconds}")
|
| 395 |
+
es.index(index=build_index, id="__meta__", document=desired_metadata(source_fingerprint))
|
| 396 |
refresh_started_at = time.time()
|
| 397 |
es.indices.refresh(index=build_index)
|
| 398 |
refresh_elapsed_seconds = round(time.time() - refresh_started_at, 2)
|
app/main.py
CHANGED
|
@@ -1,5 +1,6 @@
|
|
| 1 |
from pathlib import Path
|
| 2 |
from html import escape
|
|
|
|
| 3 |
import ipaddress
|
| 4 |
import asyncio
|
| 5 |
import json
|
|
@@ -31,8 +32,9 @@ from .indexer import acquire_index_lock, ensure_index
|
|
| 31 |
from .bucket_snapshot import publish as publish_bucket_snapshot
|
| 32 |
from .storage_lock import serving_lock
|
| 33 |
MAX_RESULT_WINDOW = 500000
|
| 34 |
-
REINDEX_COOLDOWN_SECONDS =
|
| 35 |
REINDEX_LAST_START = DATA_ROOT / "reindex-last-start"
|
|
|
|
| 36 |
INDEX_STATUS_PATH = DATA_ROOT / "index-status"
|
| 37 |
BUCKET_DIR = Path(os.environ["BHA_BUCKET_DIR"]) if os.environ.get("BHA_BUCKET_DIR") else None
|
| 38 |
app = FastAPI(title="BHA Search Lite")
|
|
@@ -655,7 +657,7 @@ def facet(kind: str, request: Request, page: int = 1, page_size: int = 200, q: s
|
|
| 655 |
status_data = cached_status_payload()
|
| 656 |
timings["status"] = (time.perf_counter() - status_started) * 1000
|
| 657 |
status = str(status_data.get("index_status") or "unknown")
|
| 658 |
-
if not FACET_DB.exists():
|
| 659 |
return {"items": [], "total": 0, "page": max(1, page), "page_size": page_size, "has_more": False, "indexing": status != "ready", "index_status": status}
|
| 660 |
lock_started = time.perf_counter()
|
| 661 |
with serving_lock():
|
|
@@ -671,7 +673,7 @@ def sources(request: Request):
|
|
| 671 |
status_data = cached_status_payload()
|
| 672 |
timings["status"] = (time.perf_counter() - status_started) * 1000
|
| 673 |
status = str(status_data.get("index_status") or "unknown")
|
| 674 |
-
if not FACET_DB.exists():
|
| 675 |
return {"sources": [], "authors": [], "tags": [], "archives": [], "types": [], "years": [], "indexing": status != "ready", "index_status": status}
|
| 676 |
lock_started = time.perf_counter()
|
| 677 |
with serving_lock():
|
|
@@ -704,7 +706,7 @@ def search(body: SearchRequest, request: Request):
|
|
| 704 |
timings = request.state.server_timings
|
| 705 |
status_started = time.perf_counter()
|
| 706 |
status = index_status()
|
| 707 |
-
has_data = status
|
| 708 |
timings["status"] = (time.perf_counter() - status_started) * 1000
|
| 709 |
if not has_data:
|
| 710 |
return timed_json_response(
|
|
@@ -783,6 +785,8 @@ def sort_clause(sort_key: str) -> list[dict[str, Any]] | None:
|
|
| 783 |
|
| 784 |
def preview(doc_id: str, request: Request):
|
| 785 |
timings = request.state.server_timings
|
|
|
|
|
|
|
| 786 |
lock_started = time.perf_counter()
|
| 787 |
with serving_lock():
|
| 788 |
timings["lock"] = (time.perf_counter() - lock_started) * 1000
|
|
@@ -799,6 +803,8 @@ def preview(doc_id: str, request: Request):
|
|
| 799 |
|
| 800 |
def random_preview(request: Request):
|
| 801 |
timings = request.state.server_timings
|
|
|
|
|
|
|
| 802 |
lock_started = time.perf_counter()
|
| 803 |
with serving_lock():
|
| 804 |
timings["lock"] = (time.perf_counter() - lock_started) * 1000
|
|
@@ -1036,9 +1042,26 @@ def run_reindex(lock_file) -> None:
|
|
| 1036 |
finally:
|
| 1037 |
clear_response_caches()
|
| 1038 |
lock_file.close()
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1039 |
@app.post("/api/reindex", status_code=202)
|
| 1040 |
|
| 1041 |
-
def reindex():
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1042 |
lock_file = acquire_index_lock(blocking=False)
|
| 1043 |
if lock_file is None:
|
| 1044 |
return JSONResponse({"error": "indexing already in progress"}, status_code=409)
|
|
@@ -1058,7 +1081,7 @@ def reindex():
|
|
| 1058 |
try:
|
| 1059 |
REINDEX_LAST_START.parent.mkdir(parents=True, exist_ok=True)
|
| 1060 |
REINDEX_LAST_START.write_text(str(now), encoding="utf-8")
|
| 1061 |
-
|
| 1062 |
except Exception:
|
| 1063 |
lock_file.close()
|
| 1064 |
REINDEX_LAST_START.unlink(missing_ok=True)
|
|
|
|
| 1 |
from pathlib import Path
|
| 2 |
from html import escape
|
| 3 |
+
import hmac
|
| 4 |
import ipaddress
|
| 5 |
import asyncio
|
| 6 |
import json
|
|
|
|
| 32 |
from .bucket_snapshot import publish as publish_bucket_snapshot
|
| 33 |
from .storage_lock import serving_lock
|
| 34 |
MAX_RESULT_WINDOW = 500000
|
| 35 |
+
REINDEX_COOLDOWN_SECONDS = 10 * 60
|
| 36 |
REINDEX_LAST_START = DATA_ROOT / "reindex-last-start"
|
| 37 |
+
REINDEX_TOKEN = os.environ.get("BHA_REINDEX_TOKEN", "")
|
| 38 |
INDEX_STATUS_PATH = DATA_ROOT / "index-status"
|
| 39 |
BUCKET_DIR = Path(os.environ["BHA_BUCKET_DIR"]) if os.environ.get("BHA_BUCKET_DIR") else None
|
| 40 |
app = FastAPI(title="BHA Search Lite")
|
|
|
|
| 657 |
status_data = cached_status_payload()
|
| 658 |
timings["status"] = (time.perf_counter() - status_started) * 1000
|
| 659 |
status = str(status_data.get("index_status") or "unknown")
|
| 660 |
+
if status == "restoring" or not FACET_DB.exists():
|
| 661 |
return {"items": [], "total": 0, "page": max(1, page), "page_size": page_size, "has_more": False, "indexing": status != "ready", "index_status": status}
|
| 662 |
lock_started = time.perf_counter()
|
| 663 |
with serving_lock():
|
|
|
|
| 673 |
status_data = cached_status_payload()
|
| 674 |
timings["status"] = (time.perf_counter() - status_started) * 1000
|
| 675 |
status = str(status_data.get("index_status") or "unknown")
|
| 676 |
+
if status == "restoring" or not FACET_DB.exists():
|
| 677 |
return {"sources": [], "authors": [], "tags": [], "archives": [], "types": [], "years": [], "indexing": status != "ready", "index_status": status}
|
| 678 |
lock_started = time.perf_counter()
|
| 679 |
with serving_lock():
|
|
|
|
| 706 |
timings = request.state.server_timings
|
| 707 |
status_started = time.perf_counter()
|
| 708 |
status = index_status()
|
| 709 |
+
has_data = status not in {"switching", "restoring"} and has_index_data()
|
| 710 |
timings["status"] = (time.perf_counter() - status_started) * 1000
|
| 711 |
if not has_data:
|
| 712 |
return timed_json_response(
|
|
|
|
| 785 |
|
| 786 |
def preview(doc_id: str, request: Request):
|
| 787 |
timings = request.state.server_timings
|
| 788 |
+
if index_status() == "restoring":
|
| 789 |
+
return JSONResponse({"error": "index restore in progress"}, status_code=503)
|
| 790 |
lock_started = time.perf_counter()
|
| 791 |
with serving_lock():
|
| 792 |
timings["lock"] = (time.perf_counter() - lock_started) * 1000
|
|
|
|
| 803 |
|
| 804 |
def random_preview(request: Request):
|
| 805 |
timings = request.state.server_timings
|
| 806 |
+
if index_status() == "restoring":
|
| 807 |
+
return JSONResponse({"error": "index restore in progress"}, status_code=503)
|
| 808 |
lock_started = time.perf_counter()
|
| 809 |
with serving_lock():
|
| 810 |
timings["lock"] = (time.perf_counter() - lock_started) * 1000
|
|
|
|
| 1042 |
finally:
|
| 1043 |
clear_response_caches()
|
| 1044 |
lock_file.close()
|
| 1045 |
+
|
| 1046 |
+
def start_reindex_worker(lock_file) -> None:
|
| 1047 |
+
threading.Thread(target=run_reindex, args=(lock_file,), daemon=True).start()
|
| 1048 |
+
|
| 1049 |
@app.post("/api/reindex", status_code=202)
|
| 1050 |
|
| 1051 |
+
def reindex(request: Request):
|
| 1052 |
+
if not REINDEX_TOKEN:
|
| 1053 |
+
return JSONResponse(
|
| 1054 |
+
{"error": "manual reindex is not configured"},
|
| 1055 |
+
status_code=503,
|
| 1056 |
+
headers={"Cache-Control": "no-store"},
|
| 1057 |
+
)
|
| 1058 |
+
scheme, _, supplied_token = request.headers.get("Authorization", "").partition(" ")
|
| 1059 |
+
if scheme.lower() != "bearer" or not supplied_token or not hmac.compare_digest(supplied_token, REINDEX_TOKEN):
|
| 1060 |
+
return JSONResponse(
|
| 1061 |
+
{"error": "invalid reindex credentials"},
|
| 1062 |
+
status_code=401,
|
| 1063 |
+
headers={"WWW-Authenticate": "Bearer", "Cache-Control": "no-store"},
|
| 1064 |
+
)
|
| 1065 |
lock_file = acquire_index_lock(blocking=False)
|
| 1066 |
if lock_file is None:
|
| 1067 |
return JSONResponse({"error": "indexing already in progress"}, status_code=409)
|
|
|
|
| 1081 |
try:
|
| 1082 |
REINDEX_LAST_START.parent.mkdir(parents=True, exist_ok=True)
|
| 1083 |
REINDEX_LAST_START.write_text(str(now), encoding="utf-8")
|
| 1084 |
+
start_reindex_worker(lock_file)
|
| 1085 |
except Exception:
|
| 1086 |
lock_file.close()
|
| 1087 |
REINDEX_LAST_START.unlink(missing_ok=True)
|