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

Upload 3 files

Browse files
Files changed (3) hide show
  1. app/bucket_snapshot.py +89 -9
  2. app/indexer.py +1 -1
  3. app/main.py +5 -0
app/bucket_snapshot.py CHANGED
@@ -8,7 +8,9 @@ import os
8
  import re
9
  import shutil
10
  import sqlite3
 
11
  import sys
 
12
  import time
13
  import uuid
14
  from pathlib import Path
@@ -16,9 +18,10 @@ from typing import Any
16
 
17
  from elasticsearch import Elasticsearch
18
 
19
- from .config import ARCHIVE_END, ARCHIVE_START, DATA_ROOT, ES_URL, INDEX_NAME, INDEX_VERSION
20
  from .doc_store import DOC_DB
21
  from .facet_store import FACET_DB
 
22
 
23
  MANIFEST_NAME = "current.json"
24
  REPOSITORY = "bha_bucket"
@@ -33,6 +36,23 @@ def expected_metadata() -> dict[str, Any]:
33
  return {"version": INDEX_VERSION, "archive_start": ARCHIVE_START, "archive_end": ARCHIVE_END}
34
 
35
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
36
  def sha256(path: Path) -> str:
37
  digest = hashlib.sha256()
38
  with path.open("rb") as source:
@@ -66,11 +86,17 @@ def read_manifest(bucket_dir: Path) -> dict[str, Any] | None:
66
  return None
67
  snapshot = data.get("snapshot")
68
  generation = data.get("generation")
 
 
69
  sidecars = data.get("sidecars")
70
  if not isinstance(snapshot, str) or not SNAPSHOT_NAME_RE.fullmatch(snapshot):
71
  return None
72
  if not isinstance(generation, str) or not SNAPSHOT_NAME_RE.fullmatch(generation):
73
  return None
 
 
 
 
74
  if not isinstance(sidecars, dict) or set(sidecars) != set(SIDECARS):
75
  return None
76
  for key, target in SIDECARS.items():
@@ -80,6 +106,19 @@ def read_manifest(bucket_dir: Path) -> dict[str, Any] | None:
80
  return data
81
 
82
 
 
 
 
 
 
 
 
 
 
 
 
 
 
83
  def publish(bucket_dir: Path) -> None:
84
  if not bucket_dir.is_dir():
85
  raise RuntimeError(f"bucket directory is unavailable: {bucket_dir}")
@@ -89,6 +128,8 @@ def publish(bucket_dir: Path) -> None:
89
  metadata = client.get(index=INDEX_NAME, id="__meta__").get("_source", {})
90
  if metadata != expected_metadata():
91
  raise RuntimeError("cannot snapshot an index with mismatched metadata")
 
 
92
  snapshot = f"bha-{int(time.time())}-{uuid.uuid4().hex[:8]}"
93
  generation = snapshot
94
  repository(client, bucket_dir)
@@ -96,22 +137,27 @@ def publish(bucket_dir: Path) -> None:
96
  client.snapshot.create(
97
  repository=REPOSITORY,
98
  snapshot=snapshot,
99
- indices=INDEX_NAME,
100
  include_global_state=False,
101
  wait_for_completion=True,
102
  )
103
  generation_dir = bucket_dir / GENERATIONS_ROOT / generation
104
  generation_dir.mkdir(parents=True, exist_ok=False)
105
  sidecars: dict[str, dict[str, Any]] = {}
106
- for key, source in SIDECARS.items():
107
- destination = generation_dir / source.name
108
- shutil.copyfile(source, destination)
109
- sidecars[key] = file_metadata(destination)
 
 
 
110
  payload = {
111
  "schema": 1,
112
  "index_name": INDEX_NAME,
113
  "index_version": INDEX_VERSION,
114
  "index_metadata": expected_metadata(),
 
 
115
  "snapshot": snapshot,
116
  "generation": generation,
117
  "document_count": int(client.count(index=INDEX_NAME, query={"exists": {"field": "doc_id"}})["count"]),
@@ -121,6 +167,17 @@ def publish(bucket_dir: Path) -> None:
121
  temporary_manifest = bucket_dir / f".{MANIFEST_NAME}.{uuid.uuid4().hex}.tmp"
122
  temporary_manifest.write_text(json.dumps(payload, ensure_ascii=False, sort_keys=True), encoding="utf-8")
123
  os.replace(temporary_manifest, manifest_path(bucket_dir))
 
 
 
 
 
 
 
 
 
 
 
124
  print(f"bha_bucket_snapshot=published snapshot={snapshot} elapsed_seconds={time.monotonic() - started:.2f}", flush=True)
125
 
126
 
@@ -137,8 +194,24 @@ def restore_sidecars(bucket_dir: Path, manifest: dict[str, Any]) -> None:
137
  temporary = DATA_ROOT / f".{target.name}.{uuid.uuid4().hex}.tmp"
138
  shutil.copyfile(source, temporary)
139
  temporary_paths.append((temporary, target))
140
- for temporary, target in temporary_paths:
141
- os.replace(temporary, target)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
142
  finally:
143
  for temporary, _ in temporary_paths:
144
  temporary.unlink(missing_ok=True)
@@ -164,6 +237,13 @@ def restore(bucket_dir: Path) -> bool:
164
  if manifest is None:
165
  print("bha_bucket_snapshot=unavailable reason=missing_or_invalid_manifest", flush=True)
166
  return False
 
 
 
 
 
 
 
167
  client = Elasticsearch(ES_URL, request_timeout=3600)
168
  if client.indices.exists(index=INDEX_NAME):
169
  print("bha_bucket_snapshot=skipped reason=index_exists", flush=True)
@@ -174,7 +254,7 @@ def restore(bucket_dir: Path) -> bool:
174
  client.snapshot.restore(
175
  repository=REPOSITORY,
176
  snapshot=str(manifest["snapshot"]),
177
- indices=INDEX_NAME,
178
  include_global_state=False,
179
  include_aliases=True,
180
  wait_for_completion=True,
 
8
  import re
9
  import shutil
10
  import sqlite3
11
+ import subprocess
12
  import sys
13
+ import tempfile
14
  import time
15
  import uuid
16
  from pathlib import Path
 
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"
 
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:
57
  digest = hashlib.sha256()
58
  with path.open("rb") as source:
 
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
94
  if not isinstance(generation, str) or not SNAPSHOT_NAME_RE.fullmatch(generation):
95
  return None
96
+ if not isinstance(physical_index, str) or not physical_index.startswith(f"{INDEX_NAME}-"):
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():
 
106
  return data
107
 
108
 
109
+ def physical_index(client: Elasticsearch) -> str:
110
+ aliases = client.indices.get_alias(name=INDEX_NAME)
111
+ names = sorted(aliases)
112
+ if len(names) != 1 or not names[0].startswith(f"{INDEX_NAME}-"):
113
+ raise RuntimeError("expected one physical index behind the article alias")
114
+ return names[0]
115
+
116
+
117
+ def backup_sqlite(source: Path, destination: Path) -> None:
118
+ with sqlite3.connect(source) as source_conn, sqlite3.connect(destination) as destination_conn:
119
+ source_conn.backup(destination_conn)
120
+
121
+
122
  def publish(bucket_dir: Path) -> None:
123
  if not bucket_dir.is_dir():
124
  raise RuntimeError(f"bucket directory is unavailable: {bucket_dir}")
 
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)
 
137
  client.snapshot.create(
138
  repository=REPOSITORY,
139
  snapshot=snapshot,
140
+ indices=physical_name,
141
  include_global_state=False,
142
  wait_for_completion=True,
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():
149
+ local_copy = Path(temporary_dir) / source.name
150
+ backup_sqlite(source, local_copy)
151
+ destination = generation_dir / source.name
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,
163
  "document_count": int(client.count(index=INDEX_NAME, query={"exists": {"field": "doc_id"}})["count"]),
 
167
  temporary_manifest = bucket_dir / f".{MANIFEST_NAME}.{uuid.uuid4().hex}.tmp"
168
  temporary_manifest.write_text(json.dumps(payload, ensure_ascii=False, sort_keys=True), encoding="utf-8")
169
  os.replace(temporary_manifest, manifest_path(bucket_dir))
170
+ try:
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
 
 
194
  temporary = DATA_ROOT / f".{target.name}.{uuid.uuid4().hex}.tmp"
195
  shutil.copyfile(source, temporary)
196
  temporary_paths.append((temporary, target))
197
+ with serving_lock(exclusive=True):
198
+ backups: list[tuple[Path, Path]] = []
199
+ try:
200
+ for _, target in temporary_paths:
201
+ if target.exists():
202
+ backup = DATA_ROOT / f".{target.name}.{uuid.uuid4().hex}.previous"
203
+ os.replace(target, backup)
204
+ backups.append((backup, target))
205
+ for temporary, target in temporary_paths:
206
+ os.replace(temporary, target)
207
+ except Exception:
208
+ for _, target in temporary_paths:
209
+ target.unlink(missing_ok=True)
210
+ for backup, target in backups:
211
+ os.replace(backup, target)
212
+ raise
213
+ for backup, _ in backups:
214
+ backup.unlink(missing_ok=True)
215
  finally:
216
  for temporary, _ in temporary_paths:
217
  temporary.unlink(missing_ok=True)
 
237
  if manifest is None:
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)
 
254
  client.snapshot.restore(
255
  repository=REPOSITORY,
256
  snapshot=str(manifest["snapshot"]),
257
+ indices=str(manifest["physical_index"]),
258
  include_global_state=False,
259
  include_aliases=True,
260
  wait_for_completion=True,
app/indexer.py CHANGED
@@ -87,7 +87,7 @@ def index_mapping() -> dict:
87
  "date_year_months": {"type": "keyword"},
88
  "date_year_days": {"type": "keyword"},
89
  "date_year_month_days": {"type": "keyword"},
90
- "content": {"type": "text", "analyzer": "cjk"},
91
  "path": {"type": "keyword"},
92
  },
93
  },
 
87
  "date_year_months": {"type": "keyword"},
88
  "date_year_days": {"type": "keyword"},
89
  "date_year_month_days": {"type": "keyword"},
90
+ "content": {"type": "text", "analyzer": "cjk", "index_phrases": True},
91
  "path": {"type": "keyword"},
92
  },
93
  },
app/main.py CHANGED
@@ -3,6 +3,7 @@ from html import escape
3
  import ipaddress
4
  import asyncio
5
  import json
 
6
  import random
7
  import shutil
8
  import socket
@@ -27,11 +28,13 @@ from .doc_store import available_years, get_doc, get_search_docs, random_doc, va
27
  from .data_loader import initialize_search_tokenizer, search_words
28
  from .facet_store import FACET_DB, list_facets, sources_payload
29
  from .indexer import acquire_index_lock, ensure_index
 
30
  from .storage_lock import serving_lock
31
  MAX_RESULT_WINDOW = 500000
32
  REINDEX_COOLDOWN_SECONDS = 30 * 24 * 60 * 60
33
  REINDEX_LAST_START = DATA_ROOT / "reindex-last-start"
34
  INDEX_STATUS_PATH = DATA_ROOT / "index-status"
 
35
  app = FastAPI(title="BHA Search Lite")
36
  app.add_middleware(
37
  CORSMiddleware,
@@ -1024,6 +1027,8 @@ def run_reindex(lock_file) -> None:
1024
  INDEX_STATUS_PATH.write_text("rebuilding", encoding="utf-8")
1025
  clear_response_caches()
1026
  ensure_index(reset=True, lock_file=lock_file)
 
 
1027
  INDEX_STATUS_PATH.write_text("ready", encoding="utf-8")
1028
  except Exception as exc:
1029
  INDEX_STATUS_PATH.write_text("ready" if index_ready() else "failed", encoding="utf-8")
 
3
  import ipaddress
4
  import asyncio
5
  import json
6
+ import os
7
  import random
8
  import shutil
9
  import socket
 
28
  from .data_loader import initialize_search_tokenizer, search_words
29
  from .facet_store import FACET_DB, list_facets, sources_payload
30
  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")
39
  app.add_middleware(
40
  CORSMiddleware,
 
1027
  INDEX_STATUS_PATH.write_text("rebuilding", encoding="utf-8")
1028
  clear_response_caches()
1029
  ensure_index(reset=True, lock_file=lock_file)
1030
+ if BUCKET_DIR is not None:
1031
+ publish_bucket_snapshot(BUCKET_DIR)
1032
  INDEX_STATUS_PATH.write_text("ready", encoding="utf-8")
1033
  except Exception as exc:
1034
  INDEX_STATUS_PATH.write_text("ready" if index_ready() else "failed", encoding="utf-8")