vomebook commited on
Commit
4ad1cab
·
verified ·
1 Parent(s): 30d446f

Upload 6 files

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