fix(ops): заливки переживают обрывы базы и сети

За неделю обе массовые заливки умерли молча и не возобновились: Википедия на
20 008 статьях из ~2 млн («server closed the connection»), PMC примерно на
85 тыс. из 100 тыс. (таймаут SSL-рукопожатия). Ни ретраев, ни возобновления.

- запись пачки повторяется с переподключением к базе (5 попыток с паузой);
- обрыв листинга бакета стоит паузы, а не всей заливки;
- у Википедии сохраняется позиция в дампе — после сбоя продолжаем с неё,
  а не проматываем с нуля, полагаясь на дедуп по ext_id;
- батч уменьшен (500 статей × 2000 отпечатков = миллион строк в одной
  транзакции — вероятная причина обрыва);
- обрезка отпечатков переведена на равномерную выборку.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
jze9
2026-09-05 19:13:01 +05:00
parent 476682741e
commit f4092e5331
2 changed files with 163 additions and 65 deletions

View File

@@ -25,7 +25,10 @@
import argparse
import bz2
import contextlib
import io
import json
import os
import re
import time
@@ -138,29 +141,45 @@ def main() -> None:
ap = argparse.ArgumentParser(description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("--limit", type=int, default=100, help="сколько статей залить")
ap.add_argument("--batch", type=int, default=500, help="статей в транзакции")
ap.add_argument("--batch", type=int, default=150, help="статей в транзакции")
ap.add_argument("--min-chars", type=int, default=2000, help="минимальная длина текста")
ap.add_argument("--fp-per-doc", type=int, default=2000, help="максимум отпечатков на статью")
ap.add_argument("--dump-file", default=None,
help="путь к заранее скачанному дампу (надёжнее, чем читать из сети)")
ap.add_argument("--state-file", default="/tmp/bulk_wiki_state.json",
help="файл с числом уже обработанных статей — для продолжения после сбоя")
args = ap.parse_args()
import psycopg2
from app.algorithms.winnowing import winnow
from app.algorithms.winnowing import sample_evenly, winnow_ordered
from app.config import settings
conn = psycopg2.connect(host=settings.POSTGRES_HOST, port=settings.POSTGRES_PORT,
dbname=settings.POSTGRES_DB, user=settings.POSTGRES_USER,
password=settings.POSTGRES_PASSWORD)
def connect():
return psycopg2.connect(
host=settings.POSTGRES_HOST, port=settings.POSTGRES_PORT,
dbname=settings.POSTGRES_DB, user=settings.POSTGRES_USER,
password=settings.POSTGRES_PASSWORD,
)
conn = connect()
added = skipped = 0
fp_total = 0
seen = 0 # сколько статей прошло через парсер (позиция в дампе)
t0 = time.time()
batch: list[tuple[str, str, str]] = []
def flush(batch):
# Позиция в дампе: прошлый прогон обрывался на 20 008 статье и начинал всё
# сначала — дедуп по ext_id спасал от дублей, но время тратилось впустую
skip_until = 0
if os.path.exists(args.state_file):
with open(args.state_file) as fh:
skip_until = json.load(fh).get("processed", 0)
if skip_until:
print(f"продолжаю с {skip_until}-й статьи дампа")
def _flush_once(batch):
"""Одна попытка записать пачку; счётчики трогает только при успехе."""
nonlocal added, skipped, fp_total
if not batch:
return
with conn.cursor() as cur:
cur.executemany(
"""INSERT INTO documents (ext_id, source, title, lang, abstract, url,
@@ -184,7 +203,9 @@ def main() -> None:
if cur.fetchone():
skipped += 1
continue
hashes = list(winnow(text))[: args.fp_per_doc]
# Равномерно по тексту, а не первые N: обрезка «с начала»
# оставляла бы хвост статьи без отпечатков
hashes = sample_evenly(winnow_ordered(text), args.fp_per_doc)
for pos, h in enumerate(hashes):
buf.write(f"{doc_id}\t{h}\t{pos}\n")
fp_total += len(hashes)
@@ -195,11 +216,42 @@ def main() -> None:
added += fresh
conn.commit()
def flush(batch):
"""Записать пачку, пережив обрыв соединения с базой.
Прошлый прогон умер целиком на `server closed the connection` — потеряв
и позицию в дампе. Здесь обрыв стоит переподключения и повтора.
"""
nonlocal conn
if not batch:
return
for attempt in range(1, 6):
try:
_flush_once(batch)
return
except psycopg2.OperationalError as e:
wait = min(60, 5 * attempt)
print(f" база недоступна ({str(e).strip()[:60]}), повтор через {wait}с "
f"[{attempt}/5]", flush=True)
time.sleep(wait)
with contextlib.suppress(Exception):
conn.close()
try:
conn = connect()
except Exception:
continue
raise RuntimeError("не удалось записать пачку после 5 попыток")
for pid, title, text in iter_pages(args.min_chars, args.dump_file):
seen += 1
if seen <= skip_until: # быстро проматываем уже обработанное
continue
batch.append((pid, title, text))
if len(batch) >= args.batch:
flush(batch)
batch = []
with open(args.state_file, "w") as fh:
json.dump({"processed": seen}, fh)
el = time.time() - t0
print(f" залито {added} · пропущено {skipped} · отпечатков {fp_total:,} · "
f"{added / max(el, 1):.1f} ст/с", flush=True)