refactor(ops): массовая заливка — в общий конвейер вместо отдельных скриптов
All checks were successful
Deploy / deploy (push) Successful in 2m28s
Deploy / test (push) Successful in 2m49s

Заливку Википедии и PMC я сделал отдельными скриптами мимо существующей
инфраструктуры: запуск руками через ssh, состояние в файле в /tmp, никакой
видимости. Результат предсказуем — за неделю обе умерли молча (обрыв базы на
20 008 статьях из 2 млн и таймаут сети на 85 тыс. из 100 тыс.), прогресс
потерялся, а узнали мы об этом через неделю. При том что рядом лежит готовый
механизм: parse_sources, прогоны со шкалой, журнал, кнопки, ретраи Celery.

Теперь это обычные типы источника — wikipedia_ru и pmc_bulk:

- заводятся и запускаются из админки, как OpenAlex или КиберЛенинка;
- показывают ту же шкалу, журнал и кнопку остановки;
- падение воркера больше не теряет прогресс: позиция продолжения хранится в
  parse_sources.resume_token (номер статьи в дампе / токен страницы бакета),
  повторный запуск берёт следующую порцию;
- укладываются в бюджет времени таска — заливка идёт порциями, а не одним
  многосуточным процессом.

Чего не хватало конвейеру для миллионов и что добавлено:
- парсеры отдают генератор, а не список: 2 млн статей в память не влезают;
- app/bulk_writer.py — запись пачками через COPY (21 тыс. строк/с против
  6.7 тыс. построчно) с переподключением к базе при обрыве;
- эмбеддинги при массовой заливке не диспатчатся: они на порядок медленнее и
  стали бы узким местом, вектора досчитываются отдельно (reembed_missing.py).

scripts/ops/bulk_ingest_*.py удалены — их работу делает конвейер.

Проверено на проде: оба парсера отдают документы, прогон через run_parser
завершается штатно, позиция продолжения сдвигается (300 → 600), повторный
запуск продолжает с неё.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
jze9
2026-09-05 20:32:53 +05:00
parent 1b1ca3b7e3
commit 26de1b9de1
15 changed files with 732 additions and 584 deletions

View File

@@ -1,5 +1,6 @@
"""Celery задачи индексации документов и проверки плагиата (уровни 1-2)."""
import contextlib
import io
from datetime import UTC, datetime
from pathlib import Path
@@ -560,12 +561,180 @@ def _parser_for(source_type: str, cfg: dict[str, Any]) -> tuple[Any, dict[str, A
"year_from": cfg.get("year_from"),
"year_to": cfg.get("year_to"),
}
elif source_type == "wikipedia_ru":
# Массовый источник: дамп читается с позиции прошлого прогона
from wikipedia_ru import WikipediaRuParser as P
kwargs = {
"limit": limit,
"dump_path": query or None, # query = путь к дампу, если задан
"skip": int(cfg.get("resume_token") or 0),
}
elif source_type == "pmc_bulk":
from pmc_bulk import PMCBulkParser as P
kwargs = {"limit": limit, "resume_token": cfg.get("resume_token")}
else:
raise ValueError(f"неизвестный тип источника: {source_type}")
return P(), kwargs
def _ingest_one_by_one(parser: Any, raw_docs: Any, prog: Any, run_id: int) -> tuple[bool, bool]:
"""Обычный путь: документ за документом через add_document.
Подходит источникам, отдающим сотни статей: работает ORM-логика с
дедупликацией, индексацией в Elasticsearch и обогащением полным текстом.
Returns:
(отменён, исчерпан бюджет времени)
"""
cancelled = exhausted = False
prog.stage = "index"
prog.count_fetched(len(raw_docs))
prog.log("info", f"получено {len(raw_docs)} документов, индексация")
_write_run(run_id, **prog.snapshot())
# Эмбеддинги диспатчим пачками, а не по одному документу: так worker-gpu
# кодирует батч разом и переписывает FAISS-индекс на диск раз в N добавлений,
# а не на каждый документ.
embed_batch: list[int] = []
batch_size = settings.EMBED_BATCH_SIZE
def _flush_embed() -> None:
if embed_batch:
celery_app.send_task(
"gpu.embed_documents", args=[list(embed_batch)], queue="queue.gpu"
)
embed_batch.clear()
for raw in raw_docs:
if not cancelled and prog.over_budget:
exhausted = True
prog.log("warning", f"бюджет времени {prog.budget_s:.0f}с исчерпан на индексации")
if cancelled or exhausted:
break
try:
doc = parser.transform(raw)
if not (doc and doc.get("title") and doc.get("ext_id")):
prog.count_result("skipped")
continue
result = add_document(doc, dispatch_embed=False)
prog.count_result(result.get("status", "error"))
if result.get("status") == "indexed":
embed_batch.append(result["doc_id"])
if len(embed_batch) >= batch_size:
_flush_embed()
except Exception as e:
prog.count_result("error")
prog.log("warning", f"документ пропущен: {e}")
logger.warning(f"run_parser: ошибка документа: {e}")
if prog.should_flush():
cancelled = _write_run(run_id, **prog.snapshot())
prog.mark_flushed()
if cancelled:
prog.log("warning", "отмена по запросу из админки")
_flush_embed()
return cancelled, exhausted
def _ingest_bulk(
parser: Any, raw_docs: Any, prog: Any, run_id: int, source_id: int
) -> tuple[bool, bool]:
"""Массовый путь: статьи приходят потоком и пишутся пачками через COPY.
Отличия от обычного пути и почему они нужны:
- парсер отдаёт генератор, поэтому счётчик «получено» растёт по ходу дела,
а не известен заранее;
- запись идёт пакетно (bulk_writer), иначе миллионы отпечатков занимают
недели вместо суток;
- позиция продолжения сохраняется в источнике, чтобы следующий прогон
начинал с места остановки, а не перечитывал дамп с начала.
Эмбеддинги здесь не диспатчатся: они считаются заметно медленнее заливки и
делали бы её узким местом. Векторы досчитываются отдельно —
`scripts/ops/reembed_missing.py`.
Returns:
(отменён, исчерпан бюджет времени)
"""
from app.bulk_writer import connect, write_batch
from app.models import ParseSource
cancelled = exhausted = False
prog.stage = "index"
prog.log("info", "массовый источник: запись пачками")
_write_run(run_id, **prog.snapshot())
conn = connect()
batch: list[dict[str, Any]] = []
resume_token: str | None = None
batch_size = settings.BULK_WRITE_BATCH
def save_resume() -> None:
"""Запомнить позицию в источнике — с неё продолжит следующий прогон."""
if resume_token is None:
return
with db_session() as session:
src = session.get(ParseSource, source_id)
if src:
src.resume_token = str(resume_token)
session.commit()
def flush() -> bool:
"""Записать накопленное; True — попросили остановиться."""
nonlocal conn, batch
if not batch:
return False
conn, counts = write_batch(conn, batch, settings.MAX_FINGERPRINTS_PER_DOC)
for _ in range(counts["added"]):
prog.count_result("indexed")
for _ in range(counts["duplicates"]):
prog.count_result("duplicate")
batch = []
save_resume()
stop = _write_run(run_id, **prog.snapshot())
prog.mark_flushed()
return stop
try:
for raw in raw_docs:
if prog.over_budget:
exhausted = True
prog.log("warning", f"бюджет времени {prog.budget_s:.0f}с исчерпан")
break
try:
doc = parser.transform(raw)
except Exception as e:
prog.count_result("error")
logger.warning(f"run_parser bulk: ошибка документа: {e}")
continue
if not (doc and doc.get("ext_id") and doc.get("text")):
prog.count_result("skipped")
continue
# Позиция продолжения: у Википедии — номер статьи в дампе,
# у PMC — токен страницы бакета
resume_token = doc.get("dump_position") or doc.get("resume_token") or resume_token
batch.append(doc)
prog.count_fetched(prog.fetched + 1)
if len(batch) >= batch_size and flush():
cancelled = True
prog.log("warning", "отмена по запросу из админки")
break
if not cancelled and flush():
cancelled = True
finally:
with contextlib.suppress(Exception):
conn.close()
return cancelled, exhausted
def _write_run(run_id: int, **fields: Any) -> bool:
"""Записать прогресс прогона и вернуть True, если запрошена отмена.
@@ -622,6 +791,8 @@ def run_parser(self, source_id: int, run_id: int | None = None) -> dict[str, Any
"year_from": src.year_from,
"year_to": src.year_to,
"limit": src.limit,
# Массовые источники продолжают с сохранённой позиции
"resume_token": src.resume_token,
}
src.last_status = "running"
src.last_error = None
@@ -676,53 +847,13 @@ def run_parser(self, source_id: int, run_id: int | None = None) -> dict[str, Any
# fetch+transform без записи в JSONL — работаем in-memory
raw_docs = parser.fetch(**fetch_kwargs, progress_cb=on_fetch_progress)
prog.stage = "index"
prog.count_fetched(len(raw_docs))
prog.log("info", f"получено {len(raw_docs)} документов, индексация")
_write_run(run_id, **prog.snapshot())
# Эмбеддинги диспатчим пачками, а не по одному документу: так worker-gpu
# кодирует батч разом и переписывает FAISS-индекс на диск раз в N добавлений,
# а не на каждый документ.
embed_batch: list[int] = []
batch_size = settings.EMBED_BATCH_SIZE
def _flush_embed() -> None:
if embed_batch:
celery_app.send_task(
"gpu.embed_documents", args=[list(embed_batch)], queue="queue.gpu"
)
embed_batch.clear()
for raw in raw_docs:
if not cancelled and prog.over_budget:
exhausted = True
prog.log("warning", f"бюджет времени {prog.budget_s:.0f}с исчерпан на индексации")
if cancelled or exhausted:
break
try:
doc = parser.transform(raw)
if not (doc and doc.get("title") and doc.get("ext_id")):
prog.count_result("skipped")
continue
result = add_document(doc, dispatch_embed=False)
prog.count_result(result.get("status", "error"))
if result.get("status") == "indexed":
embed_batch.append(result["doc_id"])
if len(embed_batch) >= batch_size:
_flush_embed()
except Exception as e:
prog.count_result("error")
prog.log("warning", f"документ пропущен: {e}")
logger.warning(f"run_parser: ошибка документа: {e}")
if prog.should_flush():
cancelled = _write_run(run_id, **prog.snapshot())
prog.mark_flushed()
if cancelled:
prog.log("warning", "отмена по запросу из админки")
_flush_embed()
if getattr(parser, "bulk", False):
# Массовые источники (дамп Википедии, бакет PMC) отдают генератор:
# миллионы статей нельзя ни держать в памяти, ни писать по одной
# через ORM — для них отдельный путь с пакетной записью
cancelled, exhausted = _ingest_bulk(parser, raw_docs, prog, run_id, source_id)
else:
cancelled, exhausted = _ingest_one_by_one(parser, raw_docs, prog, run_id)
except Exception as e:
error_msg = str(e)[:500]
prog.log("error", f"прогон упал: {error_msg}")