Spaces:
Running
Running
Upload 5 files
Browse files- app/bucket_snapshot.py +10 -0
- app/data_loader.py +48 -3
- app/doc_store.py +21 -0
- app/indexer.py +30 -8
- app/main.py +13 -3
app/bucket_snapshot.py
CHANGED
|
@@ -52,6 +52,16 @@ def snapshot_status() -> dict[str, Any] | None:
|
|
| 52 |
except (OSError, json.JSONDecodeError):
|
| 53 |
return None
|
| 54 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 55 |
|
| 56 |
def local_repository() -> Path:
|
| 57 |
return DATA_ROOT / "es-repositories" / "production"
|
|
|
|
| 52 |
except (OSError, json.JSONDecodeError):
|
| 53 |
return None
|
| 54 |
|
| 55 |
+
def publish_due(bucket_dir: Path, minimum_interval_seconds: int) -> bool:
|
| 56 |
+
if minimum_interval_seconds <= 0:
|
| 57 |
+
return True
|
| 58 |
+
try:
|
| 59 |
+
manifest = json.loads(manifest_path(bucket_dir).read_text(encoding="utf-8"))
|
| 60 |
+
created_at = int(manifest.get("created_at", 0))
|
| 61 |
+
except (OSError, ValueError, TypeError, json.JSONDecodeError):
|
| 62 |
+
return True
|
| 63 |
+
return time.time() - created_at >= minimum_interval_seconds
|
| 64 |
+
|
| 65 |
|
| 66 |
def local_repository() -> Path:
|
| 67 |
return DATA_ROOT / "es-repositories" / "production"
|
app/data_loader.py
CHANGED
|
@@ -1,5 +1,6 @@
|
|
| 1 |
import hashlib
|
| 2 |
import json
|
|
|
|
| 3 |
import re
|
| 4 |
import shutil
|
| 5 |
import subprocess
|
|
@@ -12,6 +13,7 @@ from .config import ARCHIVE_END, ARCHIVE_START, PARSED_ROOT, REPO_PREFIX
|
|
| 12 |
CJK_RE = re.compile(r"[\u3400-\u4dbf\u4e00-\u9fff]+")
|
| 13 |
SEARCH_RUN_RE = re.compile(r"[a-z0-9]+|[\u3400-\u4dbf\u4e00-\u9fff]+", re.IGNORECASE)
|
| 14 |
DOCUMENT_BUILD_VERSION = "1"
|
|
|
|
| 15 |
|
| 16 |
def initialize_search_tokenizer() -> None:
|
| 17 |
jieba.initialize()
|
|
@@ -19,14 +21,31 @@ def initialize_search_tokenizer() -> None:
|
|
| 19 |
def run(command: list[str], cwd: Path | None = None) -> None:
|
| 20 |
subprocess.run(command, cwd=str(cwd) if cwd else None, check=True)
|
| 21 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 22 |
def ensure_parsed_data(
|
| 23 |
progress: Callable[[int, int], None] | None = None,
|
| 24 |
expected_commits: dict[int, str] | None = None,
|
| 25 |
archive_ids: Iterable[int] | None = None,
|
| 26 |
-
) -> None:
|
| 27 |
PARSED_ROOT.mkdir(parents=True, exist_ok=True)
|
| 28 |
archive_ids = list(archive_ids) if archive_ids is not None else list(range(ARCHIVE_START, ARCHIVE_END + 1))
|
| 29 |
total = len(archive_ids)
|
|
|
|
| 30 |
for position, archive_id in enumerate(archive_ids, start=1):
|
| 31 |
if progress:
|
| 32 |
progress(position, total)
|
|
@@ -39,8 +58,17 @@ def ensure_parsed_data(
|
|
| 39 |
["git", "rev-parse", "HEAD"], cwd=target, check=True, capture_output=True, text=True,
|
| 40 |
).stdout.strip()
|
| 41 |
if expected and current == expected:
|
|
|
|
|
|
|
|
|
|
| 42 |
continue
|
| 43 |
run(["git", "fetch", "--depth", "1", "origin", expected or "parsed"], target)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 44 |
run(["git", "clean", "-fdx"], target)
|
| 45 |
run(["git", "checkout", "parsed"], target)
|
| 46 |
run(["git", "reset", "--hard", "FETCH_HEAD"], target)
|
|
@@ -48,16 +76,23 @@ def ensure_parsed_data(
|
|
| 48 |
["git", "rev-parse", "HEAD"], cwd=target, check=True, capture_output=True, text=True,
|
| 49 |
).stdout.strip() != expected:
|
| 50 |
raise RuntimeError(f"parsed commit mismatch for archive {archive_id}")
|
|
|
|
| 51 |
continue
|
| 52 |
if target.exists():
|
| 53 |
shutil.rmtree(target)
|
| 54 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 55 |
expected = expected_commits.get(archive_id) if expected_commits else None
|
| 56 |
if expected and subprocess.run(
|
| 57 |
["git", "rev-parse", "HEAD"], cwd=target, check=True, capture_output=True, text=True,
|
| 58 |
).stdout.strip() != expected:
|
| 59 |
run(["git", "fetch", "--depth", "1", "origin", expected], target)
|
| 60 |
run(["git", "reset", "--hard", "FETCH_HEAD"], target)
|
|
|
|
|
|
|
| 61 |
|
| 62 |
def parsed_corpus_fingerprint(
|
| 63 |
remote: bool = False, revisions: dict[int, str] | None = None
|
|
@@ -220,7 +255,10 @@ def normalize_tags(tags: Any) -> tuple[list[str], list[str]]:
|
|
| 220 |
types.append(str(tag_type).strip())
|
| 221 |
return names, types
|
| 222 |
|
| 223 |
-
def iter_documents(
|
|
|
|
|
|
|
|
|
|
| 224 |
selected_archives = (
|
| 225 |
sorted(set(archive_ids)) if archive_ids is not None else range(ARCHIVE_START, ARCHIVE_END + 1)
|
| 226 |
)
|
|
@@ -237,7 +275,14 @@ def iter_documents(archive_ids: Iterable[int] | None = None) -> Iterator[dict[st
|
|
| 237 |
publication_name_words = search_words(publication_name)
|
| 238 |
publication_files = publication.get("files") if isinstance(publication.get("files"), list) else []
|
| 239 |
book_root = metadata_path.parent
|
|
|
|
|
|
|
| 240 |
for article_path in book_root.glob("*/*.json"):
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 241 |
article_id = article_path.stem
|
| 242 |
article = read_json(article_path)
|
| 243 |
if not isinstance(article, dict):
|
|
|
|
| 1 |
import hashlib
|
| 2 |
import json
|
| 3 |
+
import os
|
| 4 |
import re
|
| 5 |
import shutil
|
| 6 |
import subprocess
|
|
|
|
| 13 |
CJK_RE = re.compile(r"[\u3400-\u4dbf\u4e00-\u9fff]+")
|
| 14 |
SEARCH_RUN_RE = re.compile(r"[a-z0-9]+|[\u3400-\u4dbf\u4e00-\u9fff]+", re.IGNORECASE)
|
| 15 |
DOCUMENT_BUILD_VERSION = "1"
|
| 16 |
+
PARSED_CACHE_ROOT = Path(os.environ["BHA_PARSED_CACHE_ROOT"]) if os.environ.get("BHA_PARSED_CACHE_ROOT") else None
|
| 17 |
|
| 18 |
def initialize_search_tokenizer() -> None:
|
| 19 |
jieba.initialize()
|
|
|
|
| 21 |
def run(command: list[str], cwd: Path | None = None) -> None:
|
| 22 |
subprocess.run(command, cwd=str(cwd) if cwd else None, check=True)
|
| 23 |
|
| 24 |
+
def cache_parsed_repository(source: Path, archive_id: int) -> None:
|
| 25 |
+
if PARSED_CACHE_ROOT is None:
|
| 26 |
+
return
|
| 27 |
+
PARSED_CACHE_ROOT.mkdir(parents=True, exist_ok=True)
|
| 28 |
+
target = PARSED_CACHE_ROOT / f"archives{archive_id}"
|
| 29 |
+
if target.is_dir() and (target / ".git").is_dir():
|
| 30 |
+
source_head = subprocess.run(["git", "rev-parse", "HEAD"], cwd=source, capture_output=True, text=True, check=True).stdout.strip()
|
| 31 |
+
cached_head = subprocess.run(["git", "rev-parse", "HEAD"], cwd=target, capture_output=True, text=True, check=True).stdout.strip()
|
| 32 |
+
if source_head == cached_head:
|
| 33 |
+
return
|
| 34 |
+
temporary = PARSED_CACHE_ROOT / f".archives{archive_id}.{os.getpid()}.tmp"
|
| 35 |
+
shutil.rmtree(temporary, ignore_errors=True)
|
| 36 |
+
shutil.copytree(source, temporary)
|
| 37 |
+
shutil.rmtree(target, ignore_errors=True)
|
| 38 |
+
temporary.replace(target)
|
| 39 |
+
|
| 40 |
def ensure_parsed_data(
|
| 41 |
progress: Callable[[int, int], None] | None = None,
|
| 42 |
expected_commits: dict[int, str] | None = None,
|
| 43 |
archive_ids: Iterable[int] | None = None,
|
| 44 |
+
) -> dict[int, set[str]] | None:
|
| 45 |
PARSED_ROOT.mkdir(parents=True, exist_ok=True)
|
| 46 |
archive_ids = list(archive_ids) if archive_ids is not None else list(range(ARCHIVE_START, ARCHIVE_END + 1))
|
| 47 |
total = len(archive_ids)
|
| 48 |
+
changed_paths: dict[int, set[str]] | None = {} if expected_commits is not None else None
|
| 49 |
for position, archive_id in enumerate(archive_ids, start=1):
|
| 50 |
if progress:
|
| 51 |
progress(position, total)
|
|
|
|
| 58 |
["git", "rev-parse", "HEAD"], cwd=target, check=True, capture_output=True, text=True,
|
| 59 |
).stdout.strip()
|
| 60 |
if expected and current == expected:
|
| 61 |
+
if changed_paths is not None:
|
| 62 |
+
changed_paths[archive_id] = set()
|
| 63 |
+
cache_parsed_repository(target, archive_id)
|
| 64 |
continue
|
| 65 |
run(["git", "fetch", "--depth", "1", "origin", expected or "parsed"], target)
|
| 66 |
+
if expected:
|
| 67 |
+
diff = subprocess.run(
|
| 68 |
+
["git", "diff", "--name-only", current, "FETCH_HEAD"],
|
| 69 |
+
cwd=target, check=True, capture_output=True, text=True,
|
| 70 |
+
).stdout.splitlines()
|
| 71 |
+
changed_paths[archive_id] = {path for path in diff if path}
|
| 72 |
run(["git", "clean", "-fdx"], target)
|
| 73 |
run(["git", "checkout", "parsed"], target)
|
| 74 |
run(["git", "reset", "--hard", "FETCH_HEAD"], target)
|
|
|
|
| 76 |
["git", "rev-parse", "HEAD"], cwd=target, check=True, capture_output=True, text=True,
|
| 77 |
).stdout.strip() != expected:
|
| 78 |
raise RuntimeError(f"parsed commit mismatch for archive {archive_id}")
|
| 79 |
+
cache_parsed_repository(target, archive_id)
|
| 80 |
continue
|
| 81 |
if target.exists():
|
| 82 |
shutil.rmtree(target)
|
| 83 |
+
cached = PARSED_CACHE_ROOT / f"archives{archive_id}" if PARSED_CACHE_ROOT is not None else None
|
| 84 |
+
if cached is not None and cached.is_dir() and (cached / ".git").exists():
|
| 85 |
+
shutil.copytree(cached, target)
|
| 86 |
+
else:
|
| 87 |
+
run(["git", "clone", "--depth", "1", "--branch", "parsed", repo, str(target)])
|
| 88 |
expected = expected_commits.get(archive_id) if expected_commits else None
|
| 89 |
if expected and subprocess.run(
|
| 90 |
["git", "rev-parse", "HEAD"], cwd=target, check=True, capture_output=True, text=True,
|
| 91 |
).stdout.strip() != expected:
|
| 92 |
run(["git", "fetch", "--depth", "1", "origin", expected], target)
|
| 93 |
run(["git", "reset", "--hard", "FETCH_HEAD"], target)
|
| 94 |
+
cache_parsed_repository(target, archive_id)
|
| 95 |
+
return changed_paths
|
| 96 |
|
| 97 |
def parsed_corpus_fingerprint(
|
| 98 |
remote: bool = False, revisions: dict[int, str] | None = None
|
|
|
|
| 255 |
types.append(str(tag_type).strip())
|
| 256 |
return names, types
|
| 257 |
|
| 258 |
+
def iter_documents(
|
| 259 |
+
archive_ids: Iterable[int] | None = None,
|
| 260 |
+
changed_paths: dict[int, set[str]] | None = None,
|
| 261 |
+
) -> Iterator[dict[str, Any]]:
|
| 262 |
selected_archives = (
|
| 263 |
sorted(set(archive_ids)) if archive_ids is not None else range(ARCHIVE_START, ARCHIVE_END + 1)
|
| 264 |
)
|
|
|
|
| 275 |
publication_name_words = search_words(publication_name)
|
| 276 |
publication_files = publication.get("files") if isinstance(publication.get("files"), list) else []
|
| 277 |
book_root = metadata_path.parent
|
| 278 |
+
metadata_relative = metadata_path.relative_to(archive_root).as_posix()
|
| 279 |
+
archive_changed = changed_paths.get(archive_id) if changed_paths is not None else None
|
| 280 |
for article_path in book_root.glob("*/*.json"):
|
| 281 |
+
if archive_changed is not None:
|
| 282 |
+
article_relative = article_path.relative_to(archive_root).as_posix()
|
| 283 |
+
tags_relative = article_path.with_suffix(".tags").relative_to(archive_root).as_posix()
|
| 284 |
+
if metadata_relative not in archive_changed and article_relative not in archive_changed and tags_relative not in archive_changed:
|
| 285 |
+
continue
|
| 286 |
article_id = article_path.stem
|
| 287 |
article = read_json(article_path)
|
| 288 |
if not isinstance(article, dict):
|
app/doc_store.py
CHANGED
|
@@ -101,6 +101,27 @@ def rebuild_years_to_conn(conn: sqlite3.Connection) -> None:
|
|
| 101 |
conn.execute("DELETE FROM years")
|
| 102 |
conn.executemany("INSERT INTO years(year) VALUES (?)", [(year,) for year in sorted(years)])
|
| 103 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 104 |
def years_from_docs(docs: list[dict[str, Any]]) -> set[int]:
|
| 105 |
years: set[int] = set()
|
| 106 |
for doc in docs:
|
|
|
|
| 101 |
conn.execute("DELETE FROM years")
|
| 102 |
conn.executemany("INSERT INTO years(year) VALUES (?)", [(year,) for year in sorted(years)])
|
| 103 |
|
| 104 |
+
def update_years_to_conn(conn: sqlite3.Connection, affected_docs: list[dict[str, Any]]) -> None:
|
| 105 |
+
affected: set[int] = set()
|
| 106 |
+
for doc in affected_docs:
|
| 107 |
+
for year in doc.get("date_years") or []:
|
| 108 |
+
if isinstance(year, int):
|
| 109 |
+
affected.add(year)
|
| 110 |
+
for value in doc.get("date_display") or []:
|
| 111 |
+
raw_year = str(value).split("/", 1)[0]
|
| 112 |
+
if raw_year.isdigit():
|
| 113 |
+
affected.add(int(raw_year))
|
| 114 |
+
if not affected:
|
| 115 |
+
return
|
| 116 |
+
placeholders = ",".join("?" for _ in affected)
|
| 117 |
+
conn.execute(f"DELETE FROM years WHERE year IN ({placeholders})", sorted(affected))
|
| 118 |
+
for year in sorted(affected):
|
| 119 |
+
if conn.execute(
|
| 120 |
+
"SELECT 1 FROM docs WHERE date_display_json LIKE ? LIMIT 1",
|
| 121 |
+
(f'%"{year}/%',),
|
| 122 |
+
).fetchone():
|
| 123 |
+
conn.execute("INSERT INTO years(year) VALUES (?)", (year,))
|
| 124 |
+
|
| 125 |
def years_from_docs(docs: list[dict[str, Any]]) -> set[int]:
|
| 126 |
years: set[int] = set()
|
| 127 |
for doc in docs:
|
app/indexer.py
CHANGED
|
@@ -9,12 +9,12 @@ import time
|
|
| 9 |
import uuid
|
| 10 |
from collections import Counter
|
| 11 |
from pathlib import Path
|
| 12 |
-
from typing import IO
|
| 13 |
from elasticsearch import Elasticsearch, helpers
|
| 14 |
from elasticsearch.exceptions import NotFoundError
|
| 15 |
from .config import DATA_ROOT, ARCHIVE_END, ARCHIVE_START, ES_URL, INDEX_NAME, INDEX_VERSION, RESET_INDEX
|
| 16 |
from .data_loader import DOCUMENT_BUILD_VERSION, ensure_parsed_data, iter_documents, parsed_archive_commits, parsed_archive_revisions, parsed_corpus_fingerprint
|
| 17 |
-
from .doc_store import DOC_DB, DOC_STORE_SCHEMA_VERSION, connect as connect_doc_db, delete_docs_to_conn, doc_row, init_db as init_doc_db, reset_db,
|
| 18 |
from .facet_store import FACET_DB, FACET_STORE_SCHEMA_VERSION, apply_facet_deltas, connect as connect_facet_db, init_db as init_facet_db, reset_db as reset_facet_db, write_facets
|
| 19 |
from .storage_lock import serving_lock
|
| 20 |
MAX_RESULT_WINDOW = 500000
|
|
@@ -202,7 +202,13 @@ def empty_facet_counters() -> dict[str, Counter[str]]:
|
|
| 202 |
def elasticsearch_document(doc: dict) -> dict:
|
| 203 |
return {key: value for key, value in doc.items() if key != "article"}
|
| 204 |
|
| 205 |
-
def incremental_build(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 206 |
source_index = serving_physical_index(es)
|
| 207 |
old_revisions = old_meta.get("archive_revisions")
|
| 208 |
expected_static = desired_metadata()
|
|
@@ -241,7 +247,7 @@ def incremental_build(es: Elasticsearch, source_fingerprint: str, revisions: dic
|
|
| 241 |
)
|
| 242 |
}
|
| 243 |
seen_ids: set[str] = set()
|
| 244 |
-
for doc in iter_documents(changed):
|
| 245 |
doc_id = doc["doc_id"]
|
| 246 |
if doc_id in seen_ids:
|
| 247 |
raise IncrementalUnsafe(f"duplicate incremental doc_id: {doc_id}")
|
|
@@ -263,7 +269,7 @@ def incremental_build(es: Elasticsearch, source_fingerprint: str, revisions: dic
|
|
| 263 |
)
|
| 264 |
delete_docs_to_conn(conn, deleted)
|
| 265 |
write_docs_to_conn(conn, upsert_docs)
|
| 266 |
-
|
| 267 |
conn.commit()
|
| 268 |
removed_facets = empty_facet_counters()
|
| 269 |
added_facets = empty_facet_counters()
|
|
@@ -499,6 +505,7 @@ def ensure_index(
|
|
| 499 |
lock_file: IO[str] | None = None,
|
| 500 |
expected_commits: dict[int, str] | None = None,
|
| 501 |
expected_revisions: dict[int, str] | None = None,
|
|
|
|
| 502 |
) -> bool:
|
| 503 |
owned_lock = lock_file is None
|
| 504 |
if lock_file is None:
|
|
@@ -508,7 +515,7 @@ def ensure_index(
|
|
| 508 |
try:
|
| 509 |
if expected_commits is None and expected_revisions is None:
|
| 510 |
return _ensure_index(reset)
|
| 511 |
-
return _ensure_index(reset, expected_commits, expected_revisions)
|
| 512 |
finally:
|
| 513 |
if owned_lock:
|
| 514 |
lock_file.close()
|
|
@@ -517,6 +524,7 @@ def _ensure_index(
|
|
| 517 |
reset: bool = False,
|
| 518 |
expected_commits: dict[int, str] | None = None,
|
| 519 |
expected_revisions: dict[int, str] | None = None,
|
|
|
|
| 520 |
) -> bool:
|
| 521 |
started_at = time.time()
|
| 522 |
write_progress(status="preparing", indexed=0, errors=0, elapsed_seconds=0)
|
|
@@ -531,11 +539,20 @@ def _ensure_index(
|
|
| 531 |
and set(old_revisions) == {str(archive_id) for archive_id in expected_revisions}
|
| 532 |
and all(old_meta.get(key) == value for key, value in desired_metadata().items())
|
| 533 |
):
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 534 |
sync_archive_ids = [
|
| 535 |
archive_id for archive_id, revision in expected_revisions.items()
|
| 536 |
if old_revisions.get(str(archive_id)) != revision
|
|
|
|
| 537 |
]
|
| 538 |
-
ensure_parsed_data(lambda current, total: write_progress(
|
| 539 |
status="syncing",
|
| 540 |
indexed=0,
|
| 541 |
errors=0,
|
|
@@ -543,6 +560,8 @@ def _ensure_index(
|
|
| 543 |
archive_total=total,
|
| 544 |
elapsed_seconds=int(time.time() - started_at),
|
| 545 |
), expected_commits=expected_commits, archive_ids=sync_archive_ids)
|
|
|
|
|
|
|
| 546 |
if expected_commits is not None:
|
| 547 |
local_commits = parsed_archive_commits(archive_ids=sync_archive_ids)
|
| 548 |
if local_commits != {
|
|
@@ -566,7 +585,10 @@ def _ensure_index(
|
|
| 566 |
return False
|
| 567 |
if not force_rebuild and has_serving_data:
|
| 568 |
try:
|
| 569 |
-
|
|
|
|
|
|
|
|
|
|
| 570 |
write_progress(
|
| 571 |
status="ready",
|
| 572 |
indexed=es.count(index=INDEX_NAME).get("count", 0),
|
|
|
|
| 9 |
import uuid
|
| 10 |
from collections import Counter
|
| 11 |
from pathlib import Path
|
| 12 |
+
from typing import IO, Iterable
|
| 13 |
from elasticsearch import Elasticsearch, helpers
|
| 14 |
from elasticsearch.exceptions import NotFoundError
|
| 15 |
from .config import DATA_ROOT, ARCHIVE_END, ARCHIVE_START, ES_URL, INDEX_NAME, INDEX_VERSION, RESET_INDEX
|
| 16 |
from .data_loader import DOCUMENT_BUILD_VERSION, ensure_parsed_data, iter_documents, parsed_archive_commits, parsed_archive_revisions, parsed_corpus_fingerprint
|
| 17 |
+
from .doc_store import DOC_DB, DOC_STORE_SCHEMA_VERSION, connect as connect_doc_db, delete_docs_to_conn, doc_row, init_db as init_doc_db, reset_db, row_to_doc, update_years_to_conn, write_docs_to_conn
|
| 18 |
from .facet_store import FACET_DB, FACET_STORE_SCHEMA_VERSION, apply_facet_deltas, connect as connect_facet_db, init_db as init_facet_db, reset_db as reset_facet_db, write_facets
|
| 19 |
from .storage_lock import serving_lock
|
| 20 |
MAX_RESULT_WINDOW = 500000
|
|
|
|
| 202 |
def elasticsearch_document(doc: dict) -> dict:
|
| 203 |
return {key: value for key, value in doc.items() if key != "article"}
|
| 204 |
|
| 205 |
+
def incremental_build(
|
| 206 |
+
es: Elasticsearch,
|
| 207 |
+
source_fingerprint: str,
|
| 208 |
+
revisions: dict[int, str],
|
| 209 |
+
old_meta: dict,
|
| 210 |
+
changed_paths: dict[int, set[str]] | None = None,
|
| 211 |
+
) -> None:
|
| 212 |
source_index = serving_physical_index(es)
|
| 213 |
old_revisions = old_meta.get("archive_revisions")
|
| 214 |
expected_static = desired_metadata()
|
|
|
|
| 247 |
)
|
| 248 |
}
|
| 249 |
seen_ids: set[str] = set()
|
| 250 |
+
for doc in iter_documents(changed, changed_paths=changed_paths):
|
| 251 |
doc_id = doc["doc_id"]
|
| 252 |
if doc_id in seen_ids:
|
| 253 |
raise IncrementalUnsafe(f"duplicate incremental doc_id: {doc_id}")
|
|
|
|
| 269 |
)
|
| 270 |
delete_docs_to_conn(conn, deleted)
|
| 271 |
write_docs_to_conn(conn, upsert_docs)
|
| 272 |
+
update_years_to_conn(conn, [*removed_docs, *upsert_docs])
|
| 273 |
conn.commit()
|
| 274 |
removed_facets = empty_facet_counters()
|
| 275 |
added_facets = empty_facet_counters()
|
|
|
|
| 505 |
lock_file: IO[str] | None = None,
|
| 506 |
expected_commits: dict[int, str] | None = None,
|
| 507 |
expected_revisions: dict[int, str] | None = None,
|
| 508 |
+
changed_archives: Iterable[int] | None = None,
|
| 509 |
) -> bool:
|
| 510 |
owned_lock = lock_file is None
|
| 511 |
if lock_file is None:
|
|
|
|
| 515 |
try:
|
| 516 |
if expected_commits is None and expected_revisions is None:
|
| 517 |
return _ensure_index(reset)
|
| 518 |
+
return _ensure_index(reset, expected_commits, expected_revisions, changed_archives)
|
| 519 |
finally:
|
| 520 |
if owned_lock:
|
| 521 |
lock_file.close()
|
|
|
|
| 524 |
reset: bool = False,
|
| 525 |
expected_commits: dict[int, str] | None = None,
|
| 526 |
expected_revisions: dict[int, str] | None = None,
|
| 527 |
+
changed_archives: Iterable[int] | None = None,
|
| 528 |
) -> bool:
|
| 529 |
started_at = time.time()
|
| 530 |
write_progress(status="preparing", indexed=0, errors=0, elapsed_seconds=0)
|
|
|
|
| 539 |
and set(old_revisions) == {str(archive_id) for archive_id in expected_revisions}
|
| 540 |
and all(old_meta.get(key) == value for key, value in desired_metadata().items())
|
| 541 |
):
|
| 542 |
+
requested_archives = set(changed_archives) if changed_archives is not None else None
|
| 543 |
+
derived_archives = [
|
| 544 |
+
archive_id for archive_id, revision in expected_revisions.items()
|
| 545 |
+
if old_revisions.get(str(archive_id)) != revision
|
| 546 |
+
]
|
| 547 |
+
if requested_archives is not None and not set(derived_archives).issubset(requested_archives):
|
| 548 |
+
print("changed_archives_hint_incomplete=using_revision_difference", flush=True)
|
| 549 |
+
requested_archives = None
|
| 550 |
sync_archive_ids = [
|
| 551 |
archive_id for archive_id, revision in expected_revisions.items()
|
| 552 |
if old_revisions.get(str(archive_id)) != revision
|
| 553 |
+
and (requested_archives is None or archive_id in requested_archives)
|
| 554 |
]
|
| 555 |
+
changed_paths = ensure_parsed_data(lambda current, total: write_progress(
|
| 556 |
status="syncing",
|
| 557 |
indexed=0,
|
| 558 |
errors=0,
|
|
|
|
| 560 |
archive_total=total,
|
| 561 |
elapsed_seconds=int(time.time() - started_at),
|
| 562 |
), expected_commits=expected_commits, archive_ids=sync_archive_ids)
|
| 563 |
+
if not isinstance(changed_paths, dict):
|
| 564 |
+
changed_paths = None
|
| 565 |
if expected_commits is not None:
|
| 566 |
local_commits = parsed_archive_commits(archive_ids=sync_archive_ids)
|
| 567 |
if local_commits != {
|
|
|
|
| 585 |
return False
|
| 586 |
if not force_rebuild and has_serving_data:
|
| 587 |
try:
|
| 588 |
+
if changed_paths is None:
|
| 589 |
+
incremental_build(es, source_fingerprint, archive_revisions, old_meta)
|
| 590 |
+
else:
|
| 591 |
+
incremental_build(es, source_fingerprint, archive_revisions, old_meta, changed_paths=changed_paths)
|
| 592 |
write_progress(
|
| 593 |
status="ready",
|
| 594 |
indexed=es.count(index=INDEX_NAME).get("count", 0),
|
app/main.py
CHANGED
|
@@ -31,7 +31,7 @@ from .search_store import SearchStoreError, get_search_docs, literal_match_ids
|
|
| 31 |
from .data_loader import initialize_search_tokenizer, search_words
|
| 32 |
from .facet_store import FACET_DB, list_facets, sources_payload
|
| 33 |
from .indexer import acquire_index_lock, ensure_index
|
| 34 |
-
from .bucket_snapshot import publish as publish_bucket_snapshot, snapshot_status as bucket_snapshot_status
|
| 35 |
from .storage_lock import serving_lock
|
| 36 |
SOURCE_MIRROR_MANIFEST_URL = os.environ.get(
|
| 37 |
"BHA_SOURCE_MIRROR_MANIFEST_URL",
|
|
@@ -80,6 +80,7 @@ except ValueError:
|
|
| 80 |
INDEX_STATUS_PATH = DATA_ROOT / "index-status"
|
| 81 |
INDEX_SWITCH_STATE_PATH = DATA_ROOT / "index-switch.json"
|
| 82 |
BUCKET_DIR = Path(os.environ["BHA_BUCKET_DIR"]) if os.environ.get("BHA_BUCKET_DIR") else None
|
|
|
|
| 83 |
app = FastAPI(title="BHA Search Lite")
|
| 84 |
app.add_middleware(
|
| 85 |
CORSMiddleware,
|
|
@@ -1842,8 +1843,10 @@ async def download_source_zip(doc_id: str):
|
|
| 1842 |
|
| 1843 |
def publish_bucket_snapshot_worker() -> None:
|
| 1844 |
try:
|
| 1845 |
-
if BUCKET_DIR is not None:
|
| 1846 |
publish_bucket_snapshot(BUCKET_DIR)
|
|
|
|
|
|
|
| 1847 |
except Exception as exc:
|
| 1848 |
print(f"bucket_snapshot_publish_failed={exc}")
|
| 1849 |
|
|
@@ -1852,6 +1855,7 @@ def run_reindex(
|
|
| 1852 |
reset: bool = False,
|
| 1853 |
expected_commits: dict[int, str] | None = None,
|
| 1854 |
expected_revisions: dict[int, str] | None = None,
|
|
|
|
| 1855 |
) -> None:
|
| 1856 |
try:
|
| 1857 |
INDEX_STATUS_PATH.write_text("rebuilding", encoding="utf-8")
|
|
@@ -1862,6 +1866,7 @@ def run_reindex(
|
|
| 1862 |
changed = ensure_index(
|
| 1863 |
reset=reset, lock_file=lock_file,
|
| 1864 |
expected_commits=expected_commits, expected_revisions=expected_revisions,
|
|
|
|
| 1865 |
)
|
| 1866 |
INDEX_STATUS_PATH.write_text("ready", encoding="utf-8")
|
| 1867 |
if changed and BUCKET_DIR is not None:
|
|
@@ -1879,10 +1884,11 @@ def start_reindex_worker(
|
|
| 1879 |
reset: bool = False,
|
| 1880 |
expected_commits: dict[int, str] | None = None,
|
| 1881 |
expected_revisions: dict[int, str] | None = None,
|
|
|
|
| 1882 |
) -> None:
|
| 1883 |
threading.Thread(
|
| 1884 |
target=run_reindex,
|
| 1885 |
-
args=(lock_file, reset, expected_commits, expected_revisions),
|
| 1886 |
daemon=True,
|
| 1887 |
).start()
|
| 1888 |
|
|
@@ -1911,6 +1917,9 @@ def reindex(request: Request, full: bool = False, payload: ReindexRequest | None
|
|
| 1911 |
return JSONResponse({"error": "invalid changed archive"}, status_code=400)
|
| 1912 |
expected_commits = {int(key): value for key, value in payload.archive_commits.items()}
|
| 1913 |
expected_revisions = {int(key): value for key, value in payload.archive_revisions.items()}
|
|
|
|
|
|
|
|
|
|
| 1914 |
try:
|
| 1915 |
last_start = int(REINDEX_LAST_START.read_text(encoding="utf-8").strip())
|
| 1916 |
except Exception:
|
|
@@ -1932,6 +1941,7 @@ def reindex(request: Request, full: bool = False, payload: ReindexRequest | None
|
|
| 1932 |
start_reindex_worker(
|
| 1933 |
lock_file, reset=full,
|
| 1934 |
expected_commits=expected_commits, expected_revisions=expected_revisions,
|
|
|
|
| 1935 |
)
|
| 1936 |
except Exception:
|
| 1937 |
lock_file.close()
|
|
|
|
| 31 |
from .data_loader import initialize_search_tokenizer, search_words
|
| 32 |
from .facet_store import FACET_DB, list_facets, sources_payload
|
| 33 |
from .indexer import acquire_index_lock, ensure_index
|
| 34 |
+
from .bucket_snapshot import publish as publish_bucket_snapshot, publish_due as bucket_snapshot_publish_due, snapshot_status as bucket_snapshot_status
|
| 35 |
from .storage_lock import serving_lock
|
| 36 |
SOURCE_MIRROR_MANIFEST_URL = os.environ.get(
|
| 37 |
"BHA_SOURCE_MIRROR_MANIFEST_URL",
|
|
|
|
| 80 |
INDEX_STATUS_PATH = DATA_ROOT / "index-status"
|
| 81 |
INDEX_SWITCH_STATE_PATH = DATA_ROOT / "index-switch.json"
|
| 82 |
BUCKET_DIR = Path(os.environ["BHA_BUCKET_DIR"]) if os.environ.get("BHA_BUCKET_DIR") else None
|
| 83 |
+
SNAPSHOT_MIN_INTERVAL_SECONDS = int(os.environ.get("BHA_SNAPSHOT_MIN_INTERVAL_SECONDS", "1800"))
|
| 84 |
app = FastAPI(title="BHA Search Lite")
|
| 85 |
app.add_middleware(
|
| 86 |
CORSMiddleware,
|
|
|
|
| 1843 |
|
| 1844 |
def publish_bucket_snapshot_worker() -> None:
|
| 1845 |
try:
|
| 1846 |
+
if BUCKET_DIR is not None and bucket_snapshot_publish_due(BUCKET_DIR, SNAPSHOT_MIN_INTERVAL_SECONDS):
|
| 1847 |
publish_bucket_snapshot(BUCKET_DIR)
|
| 1848 |
+
elif BUCKET_DIR is not None:
|
| 1849 |
+
print("bucket_snapshot_publish_skipped=minimum_interval", flush=True)
|
| 1850 |
except Exception as exc:
|
| 1851 |
print(f"bucket_snapshot_publish_failed={exc}")
|
| 1852 |
|
|
|
|
| 1855 |
reset: bool = False,
|
| 1856 |
expected_commits: dict[int, str] | None = None,
|
| 1857 |
expected_revisions: dict[int, str] | None = None,
|
| 1858 |
+
changed_archives: list[int] | None = None,
|
| 1859 |
) -> None:
|
| 1860 |
try:
|
| 1861 |
INDEX_STATUS_PATH.write_text("rebuilding", encoding="utf-8")
|
|
|
|
| 1866 |
changed = ensure_index(
|
| 1867 |
reset=reset, lock_file=lock_file,
|
| 1868 |
expected_commits=expected_commits, expected_revisions=expected_revisions,
|
| 1869 |
+
changed_archives=changed_archives,
|
| 1870 |
)
|
| 1871 |
INDEX_STATUS_PATH.write_text("ready", encoding="utf-8")
|
| 1872 |
if changed and BUCKET_DIR is not None:
|
|
|
|
| 1884 |
reset: bool = False,
|
| 1885 |
expected_commits: dict[int, str] | None = None,
|
| 1886 |
expected_revisions: dict[int, str] | None = None,
|
| 1887 |
+
changed_archives: list[int] | None = None,
|
| 1888 |
) -> None:
|
| 1889 |
threading.Thread(
|
| 1890 |
target=run_reindex,
|
| 1891 |
+
args=(lock_file, reset, expected_commits, expected_revisions, changed_archives),
|
| 1892 |
daemon=True,
|
| 1893 |
).start()
|
| 1894 |
|
|
|
|
| 1917 |
return JSONResponse({"error": "invalid changed archive"}, status_code=400)
|
| 1918 |
expected_commits = {int(key): value for key, value in payload.archive_commits.items()}
|
| 1919 |
expected_revisions = {int(key): value for key, value in payload.archive_revisions.items()}
|
| 1920 |
+
changed_archives = sorted(set(payload.changed_archives))
|
| 1921 |
+
else:
|
| 1922 |
+
changed_archives = None
|
| 1923 |
try:
|
| 1924 |
last_start = int(REINDEX_LAST_START.read_text(encoding="utf-8").strip())
|
| 1925 |
except Exception:
|
|
|
|
| 1941 |
start_reindex_worker(
|
| 1942 |
lock_file, reset=full,
|
| 1943 |
expected_commits=expected_commits, expected_revisions=expected_revisions,
|
| 1944 |
+
changed_archives=changed_archives,
|
| 1945 |
)
|
| 1946 |
except Exception:
|
| 1947 |
lock_file.close()
|