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

@@ -0,0 +1,29 @@
"""Позиция продолжения массовой заливки в источнике.
Revision ID: 007
Revises: 006
Create Date: 2026-09-05
Массовые источники (дамп Википедии, бакет PMC) заливаются частями: за один
прогон берётся столько статей, сколько влезает в бюджет времени таска. Чтобы
следующий прогон продолжал с того же места, а не перечитывал залитое заново,
позиция сохраняется прямо в источнике: для Википедии это номер статьи в дампе,
для PMC — токен страницы бакета.
"""
import sqlalchemy as sa
from alembic import op
revision = "007"
down_revision = "006"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column("parse_sources", sa.Column("resume_token", sa.Text(), nullable=True))
def downgrade() -> None:
op.drop_column("parse_sources", "resume_token")

View File

@@ -50,8 +50,10 @@ logger = logging.getLogger(__name__)
router = APIRouter(prefix="/admin", tags=["admin"], dependencies=[Depends(get_admin_user)])
# Типы источников, для которых в worker-indexer есть парсер (index.run_parser)
SOURCE_TYPES = ("openalex", "cyberleninka", "arxiv", "pmc")
# Типы источников, для которых в worker-indexer есть парсер (index.run_parser).
# wikipedia_ru и pmc_bulk — массовые: читают дамп/бакет потоком и пишут пачками,
# продолжая с сохранённой позиции (parse_sources.resume_token)
SOURCE_TYPES = ("openalex", "cyberleninka", "arxiv", "pmc", "wikipedia_ru", "pmc_bulk")
# Прогон в этих статусах ещё может двигаться — повторно запускать источник нельзя
RUN_ACTIVE_STATUSES = ("queued", "running")

View File

@@ -35,6 +35,9 @@ class ParseSource(Base):
docs_added: Mapped[int] = mapped_column(default=0)
# Последний (пока идёт — текущий) прогон, см. ParseRun
last_run_id: Mapped[int | None] = mapped_column(nullable=True)
# Позиция продолжения для массовых источников: номер статьи в дампе
# Википедии или токен страницы бакета PMC
resume_token: Mapped[str | None] = mapped_column(Text, nullable=True)
created_at: Mapped[datetime] = mapped_column(server_default=func.now())
def __repr__(self) -> str:

View File

@@ -120,7 +120,9 @@ class ParseRunDetail(ParseRunResponse):
# ─── Источники парсинга ───────────────────────────────────────────────────────
class ParseSourceCreate(BaseModel):
source_type: str = Field(description="openalex / cyberleninka / arxiv / pmc")
source_type: str = Field(
description="openalex / cyberleninka / arxiv / pmc / wikipedia_ru / pmc_bulk"
)
name: str = Field(min_length=1, max_length=255)
query: str | None = None
lang: str | None = None
@@ -164,7 +166,9 @@ class ParseSourceResponse(BaseModel):
class ParseSourceBulkCreate(BaseModel):
"""Пакетное добавление: один тип/лимит, много запросов (по строке на тему)."""
source_type: str = Field(description="openalex / cyberleninka / arxiv / pmc")
source_type: str = Field(
description="openalex / cyberleninka / arxiv / pmc / wikipedia_ru / pmc_bulk"
)
queries: list[str] = Field(min_length=1, max_length=500)
name_prefix: str = ""
lang: str | None = None