Spaces:
Running
Running
Upload 6 files
Browse files- app/bucket_snapshot.py +16 -4
- app/config.py +0 -1
- app/data_loader.py +18 -7
- app/doc_store.py +19 -0
- app/indexer.py +149 -8
- app/main.py +16 -20
app/bucket_snapshot.py
CHANGED
|
@@ -17,8 +17,8 @@ from typing import Any
|
|
| 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
|
|
@@ -104,6 +104,7 @@ def read_manifest(bucket_dir: Path) -> dict[str, Any] | None:
|
|
| 104 |
generation = data.get("generation")
|
| 105 |
physical_index = data.get("physical_index")
|
| 106 |
source_fingerprint = index_metadata.get("source_fingerprint")
|
|
|
|
| 107 |
sidecars = data.get("sidecars")
|
| 108 |
if not isinstance(snapshot, str) or not SNAPSHOT_NAME_RE.fullmatch(snapshot):
|
| 109 |
return None
|
|
@@ -113,7 +114,17 @@ def read_manifest(bucket_dir: Path) -> dict[str, Any] | None:
|
|
| 113 |
return None
|
| 114 |
if not isinstance(source_fingerprint, str) or not re.fullmatch(r"[0-9a-f]{64}", source_fingerprint):
|
| 115 |
return None
|
| 116 |
-
if
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 117 |
return None
|
| 118 |
if not isinstance(sidecars, dict) or set(sidecars) != set(SIDECARS):
|
| 119 |
return None
|
|
@@ -164,8 +175,9 @@ def publish(bucket_dir: Path) -> None:
|
|
| 164 |
raise RuntimeError("cannot snapshot missing SQLite sidecars")
|
| 165 |
client = Elasticsearch(ES_URL, request_timeout=3600)
|
| 166 |
source_fingerprint = parsed_corpus_fingerprint()
|
|
|
|
| 167 |
metadata = client.get(index=INDEX_NAME, id="__meta__").get("_source", {})
|
| 168 |
-
if metadata != desired_metadata(source_fingerprint):
|
| 169 |
raise RuntimeError("cannot snapshot an index with mismatched build or source metadata")
|
| 170 |
physical_name = physical_index(client)
|
| 171 |
snapshot = f"bha-{int(time.time())}-{uuid.uuid4().hex[:8]}"
|
|
|
|
| 17 |
|
| 18 |
from elasticsearch import Elasticsearch
|
| 19 |
|
| 20 |
+
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
|
|
|
|
| 104 |
generation = data.get("generation")
|
| 105 |
physical_index = data.get("physical_index")
|
| 106 |
source_fingerprint = index_metadata.get("source_fingerprint")
|
| 107 |
+
raw_revisions = index_metadata.get("archive_revisions")
|
| 108 |
sidecars = data.get("sidecars")
|
| 109 |
if not isinstance(snapshot, str) or not SNAPSHOT_NAME_RE.fullmatch(snapshot):
|
| 110 |
return None
|
|
|
|
| 114 |
return None
|
| 115 |
if not isinstance(source_fingerprint, str) or not re.fullmatch(r"[0-9a-f]{64}", source_fingerprint):
|
| 116 |
return None
|
| 117 |
+
if not isinstance(raw_revisions, dict):
|
| 118 |
+
return None
|
| 119 |
+
try:
|
| 120 |
+
revisions = {int(key): str(value) for key, value in raw_revisions.items()}
|
| 121 |
+
except (TypeError, ValueError):
|
| 122 |
+
return None
|
| 123 |
+
if set(revisions) != set(range(ARCHIVE_START, ARCHIVE_END + 1)):
|
| 124 |
+
return None
|
| 125 |
+
if any(not re.fullmatch(r"[0-9a-f]{40}", revision) for revision in revisions.values()):
|
| 126 |
+
return None
|
| 127 |
+
if index_metadata != desired_metadata(source_fingerprint, revisions):
|
| 128 |
return None
|
| 129 |
if not isinstance(sidecars, dict) or set(sidecars) != set(SIDECARS):
|
| 130 |
return None
|
|
|
|
| 175 |
raise RuntimeError("cannot snapshot missing SQLite sidecars")
|
| 176 |
client = Elasticsearch(ES_URL, request_timeout=3600)
|
| 177 |
source_fingerprint = parsed_corpus_fingerprint()
|
| 178 |
+
revisions = parsed_archive_revisions()
|
| 179 |
metadata = client.get(index=INDEX_NAME, id="__meta__").get("_source", {})
|
| 180 |
+
if metadata != desired_metadata(source_fingerprint, revisions):
|
| 181 |
raise RuntimeError("cannot snapshot an index with mismatched build or source metadata")
|
| 182 |
physical_name = physical_index(client)
|
| 183 |
snapshot = f"bha-{int(time.time())}-{uuid.uuid4().hex[:8]}"
|
app/config.py
CHANGED
|
@@ -6,7 +6,6 @@ PARSED_ROOT = DATA_ROOT / "parsed"
|
|
| 6 |
INDEX_NAME = os.environ.get("ES_INDEX", "article")
|
| 7 |
ES_URL = os.environ.get("ES_URL", "http://127.0.0.1:9200")
|
| 8 |
REPO_PREFIX = os.environ.get("REPO_PREFIX", "https://github.com/anftm")
|
| 9 |
-
SOURCE_REPO_OWNER = os.environ.get("SOURCE_REPO_OWNER", "anftm")
|
| 10 |
ARCHIVE_START = int(os.environ.get("ARCHIVE_START", "0"))
|
| 11 |
ARCHIVE_END = int(os.environ.get("ARCHIVE_END", "31"))
|
| 12 |
RESET_INDEX = os.environ.get("RESET_INDEX", "0") == "1"
|
|
|
|
| 6 |
INDEX_NAME = os.environ.get("ES_INDEX", "article")
|
| 7 |
ES_URL = os.environ.get("ES_URL", "http://127.0.0.1:9200")
|
| 8 |
REPO_PREFIX = os.environ.get("REPO_PREFIX", "https://github.com/anftm")
|
|
|
|
| 9 |
ARCHIVE_START = int(os.environ.get("ARCHIVE_START", "0"))
|
| 10 |
ARCHIVE_END = int(os.environ.get("ARCHIVE_END", "31"))
|
| 11 |
RESET_INDEX = os.environ.get("RESET_INDEX", "0") == "1"
|
app/data_loader.py
CHANGED
|
@@ -5,7 +5,7 @@ import shutil
|
|
| 5 |
import subprocess
|
| 6 |
import unicodedata
|
| 7 |
from pathlib import Path
|
| 8 |
-
from typing import Any, Callable, Iterator
|
| 9 |
import jieba
|
| 10 |
from .config import ARCHIVE_END, ARCHIVE_START, PARSED_ROOT, REPO_PREFIX
|
| 11 |
|
|
@@ -37,8 +37,16 @@ def ensure_parsed_data(progress: Callable[[int, int], None] | None = None) -> No
|
|
| 37 |
shutil.rmtree(target)
|
| 38 |
run(["git", "clone", "--depth", "1", "--branch", "parsed", repo, str(target)])
|
| 39 |
|
| 40 |
-
def parsed_corpus_fingerprint(
|
| 41 |
-
revisions:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 42 |
for archive_id in range(ARCHIVE_START, ARCHIVE_END + 1):
|
| 43 |
if remote:
|
| 44 |
result = subprocess.run(
|
|
@@ -59,8 +67,8 @@ def parsed_corpus_fingerprint(remote: bool = False) -> str:
|
|
| 59 |
revision = result.stdout.strip()
|
| 60 |
if not re.fullmatch(r"[0-9a-f]{40}", revision):
|
| 61 |
raise RuntimeError(f"invalid parsed revision for archive {archive_id}")
|
| 62 |
-
revisions
|
| 63 |
-
return
|
| 64 |
|
| 65 |
def read_json(path: Path) -> Any:
|
| 66 |
try:
|
|
@@ -168,8 +176,11 @@ def normalize_tags(tags: Any) -> tuple[list[str], list[str]]:
|
|
| 168 |
types.append(str(tag_type).strip())
|
| 169 |
return names, types
|
| 170 |
|
| 171 |
-
def iter_documents() -> Iterator[dict[str, Any]]:
|
| 172 |
-
|
|
|
|
|
|
|
|
|
|
| 173 |
archive_root = PARSED_ROOT / f"archives{archive_id}"
|
| 174 |
if not archive_root.exists():
|
| 175 |
continue
|
|
|
|
| 5 |
import subprocess
|
| 6 |
import unicodedata
|
| 7 |
from pathlib import Path
|
| 8 |
+
from typing import Any, Callable, Iterator, Iterable
|
| 9 |
import jieba
|
| 10 |
from .config import ARCHIVE_END, ARCHIVE_START, PARSED_ROOT, REPO_PREFIX
|
| 11 |
|
|
|
|
| 37 |
shutil.rmtree(target)
|
| 38 |
run(["git", "clone", "--depth", "1", "--branch", "parsed", repo, str(target)])
|
| 39 |
|
| 40 |
+
def parsed_corpus_fingerprint(
|
| 41 |
+
remote: bool = False, revisions: dict[int, str] | None = None
|
| 42 |
+
) -> str:
|
| 43 |
+
revisions = revisions or parsed_archive_revisions(remote=remote)
|
| 44 |
+
return hashlib.sha256(
|
| 45 |
+
"\n".join(f"{archive_id}:{revisions[archive_id]}" for archive_id in sorted(revisions)).encode()
|
| 46 |
+
).hexdigest()
|
| 47 |
+
|
| 48 |
+
def parsed_archive_revisions(remote: bool = False) -> dict[int, str]:
|
| 49 |
+
revisions: dict[int, str] = {}
|
| 50 |
for archive_id in range(ARCHIVE_START, ARCHIVE_END + 1):
|
| 51 |
if remote:
|
| 52 |
result = subprocess.run(
|
|
|
|
| 67 |
revision = result.stdout.strip()
|
| 68 |
if not re.fullmatch(r"[0-9a-f]{40}", revision):
|
| 69 |
raise RuntimeError(f"invalid parsed revision for archive {archive_id}")
|
| 70 |
+
revisions[archive_id] = revision
|
| 71 |
+
return revisions
|
| 72 |
|
| 73 |
def read_json(path: Path) -> Any:
|
| 74 |
try:
|
|
|
|
| 176 |
types.append(str(tag_type).strip())
|
| 177 |
return names, types
|
| 178 |
|
| 179 |
+
def iter_documents(archive_ids: Iterable[int] | None = None) -> Iterator[dict[str, Any]]:
|
| 180 |
+
selected_archives = (
|
| 181 |
+
sorted(set(archive_ids)) if archive_ids is not None else range(ARCHIVE_START, ARCHIVE_END + 1)
|
| 182 |
+
)
|
| 183 |
+
for archive_id in selected_archives:
|
| 184 |
archive_root = PARSED_ROOT / f"archives{archive_id}"
|
| 185 |
if not archive_root.exists():
|
| 186 |
continue
|
app/doc_store.py
CHANGED
|
@@ -74,6 +74,25 @@ def write_docs_to_conn(conn: sqlite3.Connection, docs: list[dict[str, Any]]) ->
|
|
| 74 |
year_rows,
|
| 75 |
)
|
| 76 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 77 |
def years_from_docs(docs: list[dict[str, Any]]) -> set[int]:
|
| 78 |
years: set[int] = set()
|
| 79 |
for doc in docs:
|
|
|
|
| 74 |
year_rows,
|
| 75 |
)
|
| 76 |
|
| 77 |
+
def delete_docs_to_conn(conn: sqlite3.Connection, doc_ids: list[str]) -> None:
|
| 78 |
+
if not doc_ids:
|
| 79 |
+
return
|
| 80 |
+
placeholders = ",".join("?" for _ in doc_ids)
|
| 81 |
+
conn.execute(f"DELETE FROM docs WHERE doc_id IN ({placeholders})", doc_ids)
|
| 82 |
+
|
| 83 |
+
def rebuild_years_to_conn(conn: sqlite3.Connection) -> None:
|
| 84 |
+
years: set[int] = set()
|
| 85 |
+
for (raw_dates,) in conn.execute("SELECT date_display_json FROM docs"):
|
| 86 |
+
try:
|
| 87 |
+
dates = json.loads(raw_dates or "[]")
|
| 88 |
+
except (TypeError, ValueError):
|
| 89 |
+
dates = []
|
| 90 |
+
for value in dates:
|
| 91 |
+
if isinstance(value, str) and value[:4].isdigit():
|
| 92 |
+
years.add(int(value[:4]))
|
| 93 |
+
conn.execute("DELETE FROM years")
|
| 94 |
+
conn.executemany("INSERT INTO years(year) VALUES (?)", [(year,) for year in sorted(years)])
|
| 95 |
+
|
| 96 |
def years_from_docs(docs: list[dict[str, Any]]) -> set[int]:
|
| 97 |
years: set[int] = set()
|
| 98 |
for doc in docs:
|
app/indexer.py
CHANGED
|
@@ -12,8 +12,8 @@ from typing import IO
|
|
| 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
|
| 19 |
MAX_RESULT_WINDOW = 500000
|
|
@@ -106,7 +106,7 @@ def build_fingerprint() -> str:
|
|
| 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,
|
|
@@ -115,15 +115,134 @@ def desired_metadata(source_fingerprint: str | None = None) -> dict:
|
|
| 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():
|
|
@@ -188,6 +307,14 @@ def alias_points_to(es: Elasticsearch, index_name: str) -> bool:
|
|
| 188 |
return False
|
| 189 |
return INDEX_NAME in aliases.get(index_name, {}).get("aliases", {})
|
| 190 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 191 |
def recover_interrupted_switch(es: Elasticsearch) -> None:
|
| 192 |
if not SWITCH_STATE_PATH.exists():
|
| 193 |
return
|
|
@@ -313,14 +440,28 @@ def _ensure_index(reset: bool = False) -> None:
|
|
| 313 |
archive_total=total,
|
| 314 |
elapsed_seconds=int(time.time() - started_at),
|
| 315 |
))
|
| 316 |
-
|
|
|
|
| 317 |
force_rebuild = reset or RESET_INDEX
|
| 318 |
has_serving_data = serving_data_available(es)
|
| 319 |
if es.indices.exists(index=INDEX_NAME):
|
| 320 |
count = es.count(index=INDEX_NAME).get("count", 0)
|
| 321 |
-
if not force_rebuild and count and metadata_matches(es, source_fingerprint) and sidecars_ready():
|
| 322 |
write_progress(status="ready", indexed=count, errors=0, elapsed_seconds=int(time.time() - started_at))
|
| 323 |
return
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 324 |
progressive = not has_serving_data
|
| 325 |
generation = uuid.uuid4().hex[:12]
|
| 326 |
build_index = f"{INDEX_NAME}-{generation}"
|
|
@@ -402,7 +543,7 @@ def _ensure_index(reset: bool = False) -> None:
|
|
| 402 |
write_progress(status="failed", indexed=ok, errors=error_count, elapsed_seconds=int(time.time() - started_at))
|
| 403 |
raise RuntimeError(f"bulk indexing failed with {error_count} errors")
|
| 404 |
write_facets(facet_counters, temp_facet_db)
|
| 405 |
-
es.index(index=build_index, id="__meta__", document=desired_metadata(source_fingerprint))
|
| 406 |
es.indices.refresh(index=build_index)
|
| 407 |
try:
|
| 408 |
es.indices.forcemerge(index=build_index, max_num_segments=1, request_timeout=3600)
|
|
|
|
| 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_archive_revisions, parsed_corpus_fingerprint
|
| 16 |
+
from .doc_store import DOC_DB, connect as connect_doc_db, delete_docs_to_conn, init_db as init_doc_db, reset_db, rebuild_years_to_conn, row_to_doc, 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
|
| 19 |
MAX_RESULT_WINDOW = 500000
|
|
|
|
| 106 |
digest.update(module_path.read_bytes())
|
| 107 |
return digest.hexdigest()
|
| 108 |
|
| 109 |
+
def desired_metadata(source_fingerprint: str | None = None, archive_revisions: dict[int, str] | None = None) -> dict:
|
| 110 |
metadata = {
|
| 111 |
"version": INDEX_VERSION,
|
| 112 |
"archive_start": ARCHIVE_START,
|
|
|
|
| 115 |
}
|
| 116 |
if source_fingerprint is not None:
|
| 117 |
metadata["source_fingerprint"] = source_fingerprint
|
| 118 |
+
if archive_revisions is not None:
|
| 119 |
+
metadata["archive_revisions"] = {str(k): v for k, v in sorted(archive_revisions.items())}
|
| 120 |
return metadata
|
| 121 |
|
| 122 |
+
def metadata_matches(es: Elasticsearch, source_fingerprint: str, archive_revisions: dict[int, str] | None = None) -> bool:
|
| 123 |
try:
|
| 124 |
data = es.get(index=INDEX_NAME, id="__meta__")
|
| 125 |
except Exception:
|
| 126 |
return False
|
| 127 |
source = data.get("_source", {})
|
| 128 |
+
return source == desired_metadata(source_fingerprint, archive_revisions)
|
| 129 |
+
|
| 130 |
+
class IncrementalUnsafe(RuntimeError):
|
| 131 |
+
pass
|
| 132 |
+
|
| 133 |
+
def copy_sqlite(source: Path, target: Path) -> None:
|
| 134 |
+
target.unlink(missing_ok=True)
|
| 135 |
+
with sqlite3.connect(source) as source_conn, sqlite3.connect(target) as target_conn:
|
| 136 |
+
source_conn.backup(target_conn)
|
| 137 |
+
|
| 138 |
+
def serving_physical_index(es: Elasticsearch) -> str | None:
|
| 139 |
+
try:
|
| 140 |
+
aliases = es.indices.get_alias(name=INDEX_NAME)
|
| 141 |
+
except Exception:
|
| 142 |
+
return None
|
| 143 |
+
indices = [name for name, data in aliases.items() if INDEX_NAME in data.get("aliases", {})]
|
| 144 |
+
return indices[0] if len(indices) == 1 else None
|
| 145 |
+
|
| 146 |
+
def clone_serving_index(es: Elasticsearch, source: str, target: str) -> None:
|
| 147 |
+
try:
|
| 148 |
+
es.indices.put_settings(index=source, settings={"index.blocks.write": True})
|
| 149 |
+
es.indices.clone(
|
| 150 |
+
index=source,
|
| 151 |
+
target=target,
|
| 152 |
+
body={"settings": {"index.blocks.write": False}},
|
| 153 |
+
)
|
| 154 |
+
finally:
|
| 155 |
+
es.indices.put_settings(index=source, settings={"index.blocks.write": False})
|
| 156 |
+
|
| 157 |
+
def rebuild_facets_from_docs(conn: sqlite3.Connection, target: Path) -> None:
|
| 158 |
+
counters: dict[str, Counter[str]] = {
|
| 159 |
+
"source": Counter(), "author": Counter(), "tag": Counter(),
|
| 160 |
+
"type": Counter(), "archive": Counter(),
|
| 161 |
+
}
|
| 162 |
+
for row in conn.execute("SELECT * FROM docs"):
|
| 163 |
+
update_facets(counters, row_to_doc(row))
|
| 164 |
+
write_facets(counters, target)
|
| 165 |
+
|
| 166 |
+
def incremental_build(es: Elasticsearch, source_fingerprint: str, revisions: dict[int, str], old_meta: dict) -> None:
|
| 167 |
+
source_index = serving_physical_index(es)
|
| 168 |
+
old_revisions = old_meta.get("archive_revisions")
|
| 169 |
+
expected_static = desired_metadata()
|
| 170 |
+
if (
|
| 171 |
+
not source_index
|
| 172 |
+
or not isinstance(old_revisions, dict)
|
| 173 |
+
or set(old_revisions) != {str(archive_id) for archive_id in revisions}
|
| 174 |
+
or any(old_meta.get(key) != value for key, value in expected_static.items())
|
| 175 |
+
):
|
| 176 |
+
raise IncrementalUnsafe("incremental state is unavailable")
|
| 177 |
+
changed = [
|
| 178 |
+
archive_id for archive_id in revisions
|
| 179 |
+
if old_revisions.get(str(archive_id)) != revisions[archive_id]
|
| 180 |
+
]
|
| 181 |
+
if not changed:
|
| 182 |
+
raise IncrementalUnsafe("source fingerprint changed without an archive revision change")
|
| 183 |
+
|
| 184 |
+
generation = uuid.uuid4().hex[:12]
|
| 185 |
+
build_index = f"{INDEX_NAME}-{generation}"
|
| 186 |
+
temp_doc_db = DOC_DB.with_name(f"{DOC_DB.name}.{generation}.tmp")
|
| 187 |
+
temp_facet_db = FACET_DB.with_name(f"{FACET_DB.name}.{generation}.tmp")
|
| 188 |
+
activated = False
|
| 189 |
+
try:
|
| 190 |
+
copy_sqlite(DOC_DB, temp_doc_db)
|
| 191 |
+
copy_sqlite(FACET_DB, temp_facet_db)
|
| 192 |
+
old_docs: dict[str, dict] = {}
|
| 193 |
+
new_docs: dict[str, dict] = {}
|
| 194 |
+
with connect_doc_db(temp_doc_db) as conn:
|
| 195 |
+
init_doc_db(conn)
|
| 196 |
+
placeholders = ",".join("?" for _ in changed)
|
| 197 |
+
for row in conn.execute(f"SELECT * FROM docs WHERE archive_id IN ({placeholders})", changed):
|
| 198 |
+
old_docs[row["doc_id"]] = row_to_doc(row)
|
| 199 |
+
new_docs = {doc["doc_id"]: doc for doc in iter_documents(changed)}
|
| 200 |
+
deleted = sorted(set(old_docs) - set(new_docs))
|
| 201 |
+
delete_docs_to_conn(conn, deleted)
|
| 202 |
+
write_docs_to_conn(conn, list(new_docs.values()))
|
| 203 |
+
rebuild_years_to_conn(conn)
|
| 204 |
+
conn.commit()
|
| 205 |
+
with connect_doc_db(temp_doc_db) as conn:
|
| 206 |
+
rebuild_facets_from_docs(conn, temp_facet_db)
|
| 207 |
+
|
| 208 |
+
clone_serving_index(es, source_index, build_index)
|
| 209 |
+
actions = []
|
| 210 |
+
for doc_id in sorted(set(old_docs) - set(new_docs)):
|
| 211 |
+
actions.append({"_op_type": "delete", "_index": build_index, "_id": doc_id})
|
| 212 |
+
for doc in new_docs.values():
|
| 213 |
+
actions.append({"_op_type": "index", "_index": build_index, "_id": doc["doc_id"], "_source": doc})
|
| 214 |
+
errors = [item for ok, item in helpers.streaming_bulk(
|
| 215 |
+
es.options(request_timeout=120), actions, chunk_size=BULK_CHUNK_SIZE,
|
| 216 |
+
max_chunk_bytes=BULK_MAX_BYTES, raise_on_error=False,
|
| 217 |
+
raise_on_exception=False, retry_on_status=(429, 502, 503, 504),
|
| 218 |
+
) if not ok]
|
| 219 |
+
if errors:
|
| 220 |
+
raise IncrementalUnsafe(f"incremental bulk update failed: {errors[0]}")
|
| 221 |
+
es.index(index=build_index, id="__meta__", document=desired_metadata(source_fingerprint, revisions))
|
| 222 |
+
es.indices.refresh(index=build_index)
|
| 223 |
+
with connect_doc_db(temp_doc_db) as conn:
|
| 224 |
+
sqlite_count = int(conn.execute("SELECT COUNT(*) FROM docs").fetchone()[0])
|
| 225 |
+
es_count = int(es.count(index=build_index, query={"exists": {"field": "doc_id"}}).get("count", -1))
|
| 226 |
+
if es_count != sqlite_count:
|
| 227 |
+
raise IncrementalUnsafe(
|
| 228 |
+
f"incremental document count mismatch: elasticsearch={es_count} sqlite={sqlite_count}"
|
| 229 |
+
)
|
| 230 |
+
INDEX_STATUS_PATH.write_text("switching", encoding="utf-8")
|
| 231 |
+
activate_build(es, build_index, temp_doc_db, temp_facet_db)
|
| 232 |
+
activated = True
|
| 233 |
+
INDEX_STATUS_PATH.write_text("ready", encoding="utf-8")
|
| 234 |
+
except Exception as exc:
|
| 235 |
+
uncertain = activation_uncertain(es, build_index)
|
| 236 |
+
if uncertain or isinstance(exc, IncrementalUnsafe):
|
| 237 |
+
raise
|
| 238 |
+
INDEX_STATUS_PATH.write_text("ready", encoding="utf-8")
|
| 239 |
+
raise IncrementalUnsafe(f"incremental build failed safely: {exc}") from exc
|
| 240 |
+
finally:
|
| 241 |
+
uncertain = activation_uncertain(es, build_index)
|
| 242 |
+
if not activated and not uncertain:
|
| 243 |
+
es.indices.delete(index=build_index, ignore_unavailable=True)
|
| 244 |
+
reset_db(temp_doc_db)
|
| 245 |
+
reset_facet_db(temp_facet_db)
|
| 246 |
|
| 247 |
def sidecars_ready() -> bool:
|
| 248 |
if not DOC_DB.exists() or not FACET_DB.exists():
|
|
|
|
| 307 |
return False
|
| 308 |
return INDEX_NAME in aliases.get(index_name, {}).get("aliases", {})
|
| 309 |
|
| 310 |
+
def activation_uncertain(es: Elasticsearch, index_name: str) -> bool:
|
| 311 |
+
if SWITCH_STATE_PATH.exists():
|
| 312 |
+
return True
|
| 313 |
+
try:
|
| 314 |
+
return alias_points_to(es, index_name)
|
| 315 |
+
except Exception:
|
| 316 |
+
return False
|
| 317 |
+
|
| 318 |
def recover_interrupted_switch(es: Elasticsearch) -> None:
|
| 319 |
if not SWITCH_STATE_PATH.exists():
|
| 320 |
return
|
|
|
|
| 440 |
archive_total=total,
|
| 441 |
elapsed_seconds=int(time.time() - started_at),
|
| 442 |
))
|
| 443 |
+
archive_revisions = parsed_archive_revisions()
|
| 444 |
+
source_fingerprint = parsed_corpus_fingerprint(revisions=archive_revisions)
|
| 445 |
force_rebuild = reset or RESET_INDEX
|
| 446 |
has_serving_data = serving_data_available(es)
|
| 447 |
if es.indices.exists(index=INDEX_NAME):
|
| 448 |
count = es.count(index=INDEX_NAME).get("count", 0)
|
| 449 |
+
if not force_rebuild and count and metadata_matches(es, source_fingerprint, archive_revisions) and sidecars_ready():
|
| 450 |
write_progress(status="ready", indexed=count, errors=0, elapsed_seconds=int(time.time() - started_at))
|
| 451 |
return
|
| 452 |
+
if not force_rebuild and has_serving_data:
|
| 453 |
+
try:
|
| 454 |
+
old_meta = es.get(index=INDEX_NAME, id="__meta__").get("_source", {})
|
| 455 |
+
incremental_build(es, source_fingerprint, archive_revisions, old_meta)
|
| 456 |
+
write_progress(
|
| 457 |
+
status="ready",
|
| 458 |
+
indexed=es.count(index=INDEX_NAME).get("count", 0),
|
| 459 |
+
errors=0,
|
| 460 |
+
elapsed_seconds=int(time.time() - started_at),
|
| 461 |
+
)
|
| 462 |
+
return
|
| 463 |
+
except IncrementalUnsafe as exc:
|
| 464 |
+
print(f"incremental_index_unavailable={exc}; using full rebuild")
|
| 465 |
progressive = not has_serving_data
|
| 466 |
generation = uuid.uuid4().hex[:12]
|
| 467 |
build_index = f"{INDEX_NAME}-{generation}"
|
|
|
|
| 543 |
write_progress(status="failed", indexed=ok, errors=error_count, elapsed_seconds=int(time.time() - started_at))
|
| 544 |
raise RuntimeError(f"bulk indexing failed with {error_count} errors")
|
| 545 |
write_facets(facet_counters, temp_facet_db)
|
| 546 |
+
es.index(index=build_index, id="__meta__", document=desired_metadata(source_fingerprint, archive_revisions))
|
| 547 |
es.indices.refresh(index=build_index)
|
| 548 |
try:
|
| 549 |
es.indices.forcemerge(index=build_index, max_num_segments=1, request_timeout=3600)
|
app/main.py
CHANGED
|
@@ -11,7 +11,7 @@ import socket
|
|
| 11 |
import tempfile
|
| 12 |
import threading
|
| 13 |
import time
|
| 14 |
-
from urllib.parse import quote, urlparse
|
| 15 |
from typing import Any
|
| 16 |
from zipfile import ZIP_DEFLATED, ZipFile
|
| 17 |
import httpx
|
|
@@ -24,7 +24,7 @@ from brotli_asgi import BrotliMiddleware
|
|
| 24 |
from starlette.background import BackgroundTask
|
| 25 |
from fastapi.staticfiles import StaticFiles
|
| 26 |
from pydantic import BaseModel, Field
|
| 27 |
-
from .config import APP_ROOT, DATA_ROOT, ES_URL, INDEX_NAME
|
| 28 |
from .doc_store import available_years, get_doc, random_doc, variants_for_title, warmup_db
|
| 29 |
from .search_store import SearchStoreError, get_search_docs, literal_match_ids
|
| 30 |
from .data_loader import initialize_search_tokenizer, search_words
|
|
@@ -831,14 +831,6 @@ def validate_source_url(url: str) -> str | None:
|
|
| 831 |
return "source URL IP is not allowed"
|
| 832 |
return None
|
| 833 |
|
| 834 |
-
def mirror_source_url(url: str) -> str:
|
| 835 |
-
parsed = urlparse(str(url))
|
| 836 |
-
parts = parsed.path.split("/")
|
| 837 |
-
if parsed.hostname in {"github.com", "raw.githubusercontent.com"} and len(parts) > 2 and parts[1] == "banned-historical-archives":
|
| 838 |
-
parts[1] = SOURCE_REPO_OWNER
|
| 839 |
-
return urlunparse(parsed._replace(path="/".join(parts)))
|
| 840 |
-
return str(url)
|
| 841 |
-
|
| 842 |
async def validate_source_target(url: str) -> str | None:
|
| 843 |
if error := validate_source_url(url):
|
| 844 |
return error
|
|
@@ -925,7 +917,7 @@ async def download_source(doc_id: str):
|
|
| 925 |
files = source.get("source_files") or []
|
| 926 |
if not files:
|
| 927 |
return JSONResponse({"error": "source file not available"}, status_code=404)
|
| 928 |
-
url =
|
| 929 |
suffix = Path(url.split("?", 1)[0]).suffix or ".bin"
|
| 930 |
filename = safe_filename(source_filename_base(source, doc_id), suffix)
|
| 931 |
try:
|
|
@@ -964,8 +956,7 @@ async def download_source_zip(doc_id: str):
|
|
| 964 |
downloaded: list[tuple[Path, str]] = []
|
| 965 |
total_bytes = 0
|
| 966 |
try:
|
| 967 |
-
for index,
|
| 968 |
-
url = mirror_source_url(source_url)
|
| 969 |
source_path = temp_dir / f"source-{index:03d}"
|
| 970 |
size = await download_source_to_path(url, source_path, MAX_SOURCE_ZIP_BYTES - total_bytes)
|
| 971 |
total_bytes += size
|
|
@@ -996,11 +987,11 @@ async def download_source_zip(doc_id: str):
|
|
| 996 |
background=BackgroundTask(shutil.rmtree, temp_dir, ignore_errors=True),
|
| 997 |
)
|
| 998 |
|
| 999 |
-
def run_reindex(lock_file) -> None:
|
| 1000 |
try:
|
| 1001 |
INDEX_STATUS_PATH.write_text("rebuilding", encoding="utf-8")
|
| 1002 |
clear_response_caches()
|
| 1003 |
-
ensure_index(reset=
|
| 1004 |
if BUCKET_DIR is not None:
|
| 1005 |
publish_bucket_snapshot(BUCKET_DIR)
|
| 1006 |
INDEX_STATUS_PATH.write_text("ready", encoding="utf-8")
|
|
@@ -1012,12 +1003,12 @@ def run_reindex(lock_file) -> None:
|
|
| 1012 |
clear_response_caches()
|
| 1013 |
lock_file.close()
|
| 1014 |
|
| 1015 |
-
def start_reindex_worker(lock_file) -> None:
|
| 1016 |
-
threading.Thread(target=run_reindex, args=(lock_file,), daemon=True).start()
|
| 1017 |
|
| 1018 |
@app.post("/api/reindex", status_code=202)
|
| 1019 |
|
| 1020 |
-
def reindex(request: Request):
|
| 1021 |
auth_error = admin_auth_error(request)
|
| 1022 |
if auth_error is not None:
|
| 1023 |
return auth_error
|
|
@@ -1040,12 +1031,17 @@ def reindex(request: Request):
|
|
| 1040 |
try:
|
| 1041 |
REINDEX_LAST_START.parent.mkdir(parents=True, exist_ok=True)
|
| 1042 |
REINDEX_LAST_START.write_text(str(now), encoding="utf-8")
|
| 1043 |
-
start_reindex_worker(lock_file)
|
| 1044 |
except Exception:
|
| 1045 |
lock_file.close()
|
| 1046 |
REINDEX_LAST_START.unlink(missing_ok=True)
|
| 1047 |
raise
|
| 1048 |
-
return {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1049 |
static_dir = APP_ROOT / "static"
|
| 1050 |
app.mount("/static", StaticFiles(directory=static_dir), name="static")
|
| 1051 |
@app.get("/")
|
|
|
|
| 11 |
import tempfile
|
| 12 |
import threading
|
| 13 |
import time
|
| 14 |
+
from urllib.parse import quote, urlparse
|
| 15 |
from typing import Any
|
| 16 |
from zipfile import ZIP_DEFLATED, ZipFile
|
| 17 |
import httpx
|
|
|
|
| 24 |
from starlette.background import BackgroundTask
|
| 25 |
from fastapi.staticfiles import StaticFiles
|
| 26 |
from pydantic import BaseModel, Field
|
| 27 |
+
from .config import APP_ROOT, DATA_ROOT, ES_URL, INDEX_NAME
|
| 28 |
from .doc_store import available_years, get_doc, random_doc, variants_for_title, warmup_db
|
| 29 |
from .search_store import SearchStoreError, get_search_docs, literal_match_ids
|
| 30 |
from .data_loader import initialize_search_tokenizer, search_words
|
|
|
|
| 831 |
return "source URL IP is not allowed"
|
| 832 |
return None
|
| 833 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 834 |
async def validate_source_target(url: str) -> str | None:
|
| 835 |
if error := validate_source_url(url):
|
| 836 |
return error
|
|
|
|
| 917 |
files = source.get("source_files") or []
|
| 918 |
if not files:
|
| 919 |
return JSONResponse({"error": "source file not available"}, status_code=404)
|
| 920 |
+
url = str(files[0])
|
| 921 |
suffix = Path(url.split("?", 1)[0]).suffix or ".bin"
|
| 922 |
filename = safe_filename(source_filename_base(source, doc_id), suffix)
|
| 923 |
try:
|
|
|
|
| 956 |
downloaded: list[tuple[Path, str]] = []
|
| 957 |
total_bytes = 0
|
| 958 |
try:
|
| 959 |
+
for index, url in enumerate(files, start=1):
|
|
|
|
| 960 |
source_path = temp_dir / f"source-{index:03d}"
|
| 961 |
size = await download_source_to_path(url, source_path, MAX_SOURCE_ZIP_BYTES - total_bytes)
|
| 962 |
total_bytes += size
|
|
|
|
| 987 |
background=BackgroundTask(shutil.rmtree, temp_dir, ignore_errors=True),
|
| 988 |
)
|
| 989 |
|
| 990 |
+
def run_reindex(lock_file, reset: bool = False) -> None:
|
| 991 |
try:
|
| 992 |
INDEX_STATUS_PATH.write_text("rebuilding", encoding="utf-8")
|
| 993 |
clear_response_caches()
|
| 994 |
+
ensure_index(reset=reset, lock_file=lock_file)
|
| 995 |
if BUCKET_DIR is not None:
|
| 996 |
publish_bucket_snapshot(BUCKET_DIR)
|
| 997 |
INDEX_STATUS_PATH.write_text("ready", encoding="utf-8")
|
|
|
|
| 1003 |
clear_response_caches()
|
| 1004 |
lock_file.close()
|
| 1005 |
|
| 1006 |
+
def start_reindex_worker(lock_file, reset: bool = False) -> None:
|
| 1007 |
+
threading.Thread(target=run_reindex, args=(lock_file, reset), daemon=True).start()
|
| 1008 |
|
| 1009 |
@app.post("/api/reindex", status_code=202)
|
| 1010 |
|
| 1011 |
+
def reindex(request: Request, full: bool = False):
|
| 1012 |
auth_error = admin_auth_error(request)
|
| 1013 |
if auth_error is not None:
|
| 1014 |
return auth_error
|
|
|
|
| 1031 |
try:
|
| 1032 |
REINDEX_LAST_START.parent.mkdir(parents=True, exist_ok=True)
|
| 1033 |
REINDEX_LAST_START.write_text(str(now), encoding="utf-8")
|
| 1034 |
+
start_reindex_worker(lock_file, reset=full)
|
| 1035 |
except Exception:
|
| 1036 |
lock_file.close()
|
| 1037 |
REINDEX_LAST_START.unlink(missing_ok=True)
|
| 1038 |
raise
|
| 1039 |
+
return {
|
| 1040 |
+
"ok": True,
|
| 1041 |
+
"status": "accepted",
|
| 1042 |
+
"mode": "full" if full else "incremental",
|
| 1043 |
+
"cooldown_seconds": REINDEX_COOLDOWN_SECONDS,
|
| 1044 |
+
}
|
| 1045 |
static_dir = APP_ROOT / "static"
|
| 1046 |
app.mount("/static", StaticFiles(directory=static_dir), name="static")
|
| 1047 |
@app.get("/")
|