vomebook commited on
Commit
c018d8c
·
verified ·
1 Parent(s): de1141b

Upload 5 files

Browse files
Files changed (5) hide show
  1. app/bucket_probe.py +25 -3
  2. app/bucket_snapshot.py +51 -43
  3. app/data_loader.py +26 -0
  4. app/indexer.py +29 -7
  5. 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
- SNAPSHOT_ROOT = "snapshots/probe"
 
 
 
 
 
 
 
 
 
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(bucket_dir / SNAPSHOT_ROOT)}},
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 ARCHIVE_END, ARCHIVE_START, DATA_ROOT, ES_URL, INDEX_NAME, INDEX_VERSION, REPO_PREFIX
 
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 expected_metadata() -> dict[str, Any]:
36
- return {"version": INDEX_VERSION, "archive_start": ARCHIVE_START, "archive_end": ARCHIVE_END}
37
 
38
 
39
- def corpus_fingerprint(remote: bool) -> str:
40
- revisions: list[str] = []
41
- for archive_id in range(ARCHIVE_START, ARCHIVE_END + 1):
42
- if remote:
43
- command = ["git", "ls-remote", f"{REPO_PREFIX}/banned-historical-archives{archive_id}.git", "refs/heads/parsed"]
44
- result = subprocess.run(command, check=True, capture_output=True, text=True)
45
- revision = result.stdout.split(maxsplit=1)[0]
46
  else:
47
- repository_path = DATA_ROOT / "parsed" / f"archives{archive_id}"
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, bucket_dir: Path) -> None:
69
  client.snapshot.create_repository(
70
  name=REPOSITORY,
71
- repository={"type": "fs", "settings": {"location": str(bucket_dir / SNAPSHOT_ROOT)}},
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") != 1 or data.get("index_metadata") != expected_metadata():
 
 
 
86
  return None
87
  snapshot = data.get("snapshot")
88
  generation = data.get("generation")
89
  physical_index = data.get("physical_index")
90
- source_fingerprint = data.get("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 != expected_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
- repository(client, bucket_dir)
 
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": 1,
156
  "index_name": INDEX_NAME,
157
- "index_version": INDEX_VERSION,
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
- snapshots = client.snapshot.get(repository=REPOSITORY, snapshot="_all").get("snapshots", [])
175
- for previous in snapshots:
176
- previous_name = previous.get("snapshot")
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"] != corpus_fingerprint(remote=True):
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=unavailable reason=source_fingerprint_{type(exc).__name__}", flush=True)
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
- repository(client, bucket_dir)
 
 
 
 
 
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 != expected_metadata() or count != int(manifest["document_count"]):
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
- client.indices.delete(index=INDEX_NAME, ignore_unavailable=True)
 
 
 
 
 
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 desired_metadata() -> dict:
97
- return {"version": INDEX_VERSION, "archive_start": ARCHIVE_START, "archive_end": ARCHIVE_END}
 
 
 
 
 
 
 
 
 
98
 
99
- def metadata_matches(es: Elasticsearch) -> bool:
 
 
 
 
 
 
 
 
 
 
 
100
  try:
101
  data = es.get(index=INDEX_NAME, id="__meta__")
102
  except Exception:
103
  return False
104
  source = data.get("_source", {})
105
- return all(source.get(key) == value for key, value in desired_metadata().items())
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 = 30 * 24 * 60 * 60
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 != "switching" and has_index_data()
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
- threading.Thread(target=run_reindex, args=(lock_file,), daemon=True).start()
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)