vomebook commited on
Commit
a82ca2e
·
verified ·
1 Parent(s): 491b688

Upload 5 files

Browse files
Files changed (5) hide show
  1. app/bucket_snapshot.py +10 -0
  2. app/data_loader.py +48 -3
  3. app/doc_store.py +21 -0
  4. app/indexer.py +30 -8
  5. 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
- run(["git", "clone", "--depth", "1", "--branch", "parsed", repo, str(target)])
 
 
 
 
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(archive_ids: Iterable[int] | None = None) -> Iterator[dict[str, Any]]:
 
 
 
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, rebuild_years_to_conn, row_to_doc, 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,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(es: Elasticsearch, source_fingerprint: str, revisions: dict[int, str], old_meta: dict) -> None:
 
 
 
 
 
 
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
- rebuild_years_to_conn(conn)
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
- incremental_build(es, source_fingerprint, archive_revisions, old_meta)
 
 
 
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()