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,15 +25,15 @@
залитые пропускаются. Прогресс листинга сохраняется в --state-file, поэтому
прерванная заливка продолжается с той же страницы бакета.
Проверено на проде 2026-08-31: 100 статей залито, 183 090 отпечатков (1831 на
статью — реальная глубина, а не аннотация). Темп — 0.3 статьи/с, то есть
миллион в один поток занял бы около 40 суток: скачивание тут не узкое место
(12 статей/с в 12 потоков), упирается в последовательную обработку одного
документа. Для миллионов обработку нужно распараллелить по ядрам воркера —
это следующий шаг, до него скрипт годится для порций в десятки тысяч.
Проверено на проде: 1831 отпечаток на статью — реальная глубина, а не аннотация.
Темп ~2 статьи/с в один поток (0.3 ст/с из первого замера были получены, когда
база одновременно считала эмбеддинги). Скачивание не узкое место (12 статей/с в
12 потоков), упирается в последовательную обработку документа — для миллионов
её нужно распараллелить по ядрам воркера.
"""
import argparse
import contextlib
import io
import json
import os
@@ -93,6 +93,76 @@ def fetch_one(client, art_id: str) -> dict | None:
}
def write_batch(conn, connect, docs: list[dict], fp_limit: int) -> tuple:
"""Записать пачку статей, пережив обрыв соединения с базой.
Прошлые прогоны умирали целиком от `server closed the connection`, теряя и
позицию в бакете. Здесь обрыв стоит переподключения и повтора.
Returns:
(соединение, добавлено, пропущено, отпечатков) — соединение может быть
новым, если пришлось переподключаться
"""
import psycopg2
from app.algorithms.winnowing import sample_evenly, winnow_ordered
for attempt in range(1, 6):
try:
added = skipped = fp_count = 0
with conn.cursor() as cur:
# ON CONFLICT — повторный прогон не плодит дубли
cur.executemany(
"""INSERT INTO documents (ext_id, source, title, doi, year, lang, journal,
abstract, authors, indexed_at)
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,'[]'::json, now())
ON CONFLICT (ext_id) DO NOTHING""",
[(d["ext_id"], "pmc", d["title"], d["doi"], d["year"], "en",
d["journal"], d["text"][:2000]) for d in docs],
)
cur.execute("SELECT id, ext_id FROM documents WHERE ext_id = ANY(%s)",
([d["ext_id"] for d in docs],))
id_by_ext = {e: i for i, e in cur.fetchall()}
# COPY вместо построчных INSERT — единственный способ писать
# миллиарды строк за разумное время
buf = io.StringIO()
for d in docs:
doc_id = id_by_ext.get(d["ext_id"])
if doc_id is None:
continue
cur.execute("SELECT 1 FROM fingerprints WHERE doc_id=%s LIMIT 1", (doc_id,))
if cur.fetchone():
skipped += 1
continue
# Равномерно по тексту, а не первые N: иначе хвост статьи
# остаётся без отпечатков
hashes = sample_evenly(winnow_ordered(d["text"]), fp_limit)
for pos, h in enumerate(hashes):
buf.write(f"{doc_id}\t{h}\t{pos}\n")
fp_count += len(hashes)
added += 1
buf.seek(0)
if added:
cur.copy_from(buf, "fingerprints",
columns=("doc_id", "hash_value", "position"))
conn.commit()
return conn, added, skipped, fp_count
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()
with contextlib.suppress(Exception):
conn = connect()
raise RuntimeError("не удалось записать пачку после 5 попыток")
def main() -> None:
ap = argparse.ArgumentParser(description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter)
@@ -105,7 +175,6 @@ def main() -> None:
import httpx
import psycopg2
from app.algorithms.winnowing import winnow
from app.config import settings
token = None
@@ -114,19 +183,31 @@ def main() -> None:
token = json.load(fh).get("token")
print("продолжаю листинг с сохранённой позиции")
client = httpx.Client(timeout=60, headers={"User-Agent": "AcademicHelper/1.0 (noreply@jze9.ru)"})
conn = psycopg2.connect(host=settings.POSTGRES_HOST, port=settings.POSTGRES_PORT,
dbname=settings.POSTGRES_DB, user=settings.POSTGRES_USER,
password=settings.POSTGRES_PASSWORD)
client = httpx.Client(timeout=60,
headers={"User-Agent": "AcademicHelper/1.0 (noreply@jze9.ru)"})
added = skipped = failed = 0
fp_total = 0
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 = failed = fp_total = done = 0
t0 = time.time()
done = 0
while done < args.limit:
want = min(args.batch, args.limit - done)
ids, token = list_articles(client, token, want)
# Сеть у бакета иногда отваливается (прошлый прогон умер на таймауте
# SSL-рукопожатия) — это стоит паузы, а не всей заливки
try:
ids, token = list_articles(client, token, want)
except Exception as e:
print(f" листинг не удался ({type(e).__name__}), пауза 30с", flush=True)
time.sleep(30)
continue
if not ids:
print("бакет закончился")
break
@@ -138,46 +219,10 @@ def main() -> None:
if not docs:
continue
with conn.cursor() as cur:
# Документы: ON CONFLICT — повторный прогон не плодит дубли
rows = [
(d["ext_id"], "pmc", d["title"], d["doi"], d["year"], "en",
d["journal"], d["text"][:2000])
for d in docs
]
cur.executemany(
"""INSERT INTO documents (ext_id, source, title, doi, year, lang, journal,
abstract, authors, indexed_at)
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,'[]'::json, now())
ON CONFLICT (ext_id) DO NOTHING""",
rows,
)
cur.execute("SELECT id, ext_id FROM documents WHERE ext_id = ANY(%s)",
([d["ext_id"] for d in docs],))
id_by_ext = {e: i for i, e in cur.fetchall()}
# Отпечатки: COPY вместо построчных INSERT — единственный способ
# писать миллиарды строк за разумное время
buf = io.StringIO()
fresh = 0
for d in docs:
doc_id = id_by_ext.get(d["ext_id"])
if doc_id is None:
continue
cur.execute("SELECT 1 FROM fingerprints WHERE doc_id=%s LIMIT 1", (doc_id,))
if cur.fetchone():
skipped += 1
continue
hashes = list(winnow(d["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)
fresh += 1
buf.seek(0)
if fresh:
cur.copy_from(buf, "fingerprints", columns=("doc_id", "hash_value", "position"))
added += fresh
conn.commit()
conn, n_added, n_skipped, n_fp = write_batch(conn, connect, docs, args.fp_per_doc)
added += n_added
skipped += n_skipped
fp_total += n_fp
with open(args.state_file, "w") as fh:
json.dump({"token": token}, fh)
@@ -194,7 +239,8 @@ def main() -> None:
print(f"\nГотово за {el / 60:.1f} мин: залито {added}, пропущено {skipped}, "
f"недоступно {failed}, отпечатков {fp_total:,}")
if added:
print(f"темп: {added / el:.1f} статей/с → миллион за ~{1e6 / max(added / el, 0.01) / 3600:.0f} ч")
print(f"темп: {added / el:.1f} статей/с → "
f"миллион за ~{1e6 / max(added / el, 0.01) / 3600:.0f} ч")
if __name__ == "__main__":