feat(admin): шкала загрузки источников, отладка и загрузка работ в корпус
Заливка корпуса была чёрным ящиком: у источника только last_status (idle/running/done/error), без «сколько из скольки», без причины падения и без способа остановить начатое. Теперь каждый запуск создаёт строку parse_runs, куда воркер раз в ~2с пишет стадию, счётчики и журнал событий. Админка: - шкала загрузки у каждого источника (0→50% выборка, 50→100% индексация), раскрытая строка — журнал прогона по шагам с таймингами; - «Запустить всё» / «Остановить всё» и остановка по одному источнику (кооперативная отмена: воркер останавливается сам, не рвя запись в базу); - пакетное добавление источников (тип + список тем), тип pmc в форме; - загрузка PDF/DOCX/TXT прямо в базу сравнения (index.ingest_upload); - страница «Отладка»: воркеры Celery и их текущие таски, очереди RabbitMQ, покрытие корпуса эмбеддингами, зависшие и упавшие прогоны, конфиг бэкендов. Защита от краш-лупа по consumer_timeout RabbitMQ (docs/DR-HA.md §6), без неё массовый запуск 170+ источников гарантированно ронял воркер: - PARSER_TIME_BUDGET_S (1500с) — прогон закругляется сам и помечается partial; - worker_prefetch_multiplier=1 — таймаут считается от ДОСТАВКИ сообщения, и с дефолтным префетчем очередь долгих run_parser убивала канал на задачах, которые ещё не начинались. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -5,12 +5,17 @@
|
||||
"""
|
||||
|
||||
import contextlib
|
||||
import io
|
||||
import logging
|
||||
from datetime import datetime
|
||||
import os
|
||||
import uuid
|
||||
from datetime import datetime, timedelta
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy import delete, func, select
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, UploadFile, status
|
||||
from fastapi.concurrency import run_in_threadpool
|
||||
from sqlalchemy import delete, func, select, text
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
@@ -19,7 +24,7 @@ from app.core.celery_app import celery_app
|
||||
from app.core.minio_client import get_minio
|
||||
from app.core.redis_client import get_redis
|
||||
from app.database import get_db
|
||||
from app.models.admin import ParseSource, StagedWork
|
||||
from app.models.admin import ParseRun, ParseSource, StagedWork
|
||||
from app.models.document import Document, Fingerprint
|
||||
from app.models.task import Task
|
||||
from app.models.user import User
|
||||
@@ -30,6 +35,9 @@ from app.schemas.admin import (
|
||||
AdminTaskResponse,
|
||||
AdminUserResponse,
|
||||
AdminUserUpdate,
|
||||
ParseRunDetail,
|
||||
ParseRunResponse,
|
||||
ParseSourceBulkCreate,
|
||||
ParseSourceCreate,
|
||||
ParseSourceResponse,
|
||||
ParseSourceUpdate,
|
||||
@@ -42,6 +50,11 @@ 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")
|
||||
# Прогон в этих статусах ещё может двигаться — повторно запускать источник нельзя
|
||||
RUN_ACTIVE_STATUSES = ("queued", "running")
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
# СЕССИЯ ДОСТУПА
|
||||
@@ -86,6 +99,28 @@ async def verify_session(
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
# ДАШБОРД / СЕРВИСЫ
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
async def _rabbitmq_queues() -> dict:
|
||||
"""Очереди queue.* из management API RabbitMQ: {имя: сырой словарь очереди}.
|
||||
|
||||
При недоступности брокера/плагина management возвращает {"error": ...} —
|
||||
это не повод валить весь дашборд.
|
||||
"""
|
||||
try:
|
||||
# amqp://user:pass@host:port/
|
||||
creds, _, hostpart = settings.RABBITMQ_URL.split("//", 1)[1].rpartition("@")
|
||||
rmq_user, _, rmq_pass = creds.partition(":")
|
||||
rmq_host = hostpart.split(":")[0].split("/")[0]
|
||||
async with httpx.AsyncClient(timeout=5) as c:
|
||||
resp = await c.get(f"http://{rmq_host}:15672/api/queues", auth=(rmq_user, rmq_pass))
|
||||
if resp.status_code != 200:
|
||||
return {"error": f"management API вернул {resp.status_code}"}
|
||||
return {
|
||||
q["name"]: q for q in resp.json() if str(q.get("name", "")).startswith("queue.")
|
||||
}
|
||||
except Exception as e:
|
||||
return {"error": str(e)[:120]}
|
||||
|
||||
|
||||
@router.get("/health", response_model=list[ServiceHealth])
|
||||
async def health(db: AsyncSession = Depends(get_db)) -> list[ServiceHealth]:
|
||||
"""Статус всех сервисов инфраструктуры."""
|
||||
@@ -183,23 +218,12 @@ async def stats(db: AsyncSession = Depends(get_db)) -> AdminStats:
|
||||
storage = {"error": str(e)[:200]}
|
||||
|
||||
# Длины очередей через RabbitMQ management API (если доступно)
|
||||
queues: dict = {}
|
||||
try:
|
||||
rmq_user = settings.RABBITMQ_URL.split("//")[1].split(":")[0]
|
||||
rmq_pass = settings.RABBITMQ_URL.split(":")[2].split("@")[0]
|
||||
rmq_host = settings.RABBITMQ_URL.split("@")[1].split(":")[0]
|
||||
async with httpx.AsyncClient(timeout=5) as c:
|
||||
resp = await c.get(
|
||||
f"http://{rmq_host}:15672/api/queues",
|
||||
auth=(rmq_user, rmq_pass),
|
||||
)
|
||||
if resp.status_code == 200:
|
||||
for q in resp.json():
|
||||
name = q.get("name", "")
|
||||
if name.startswith("queue."):
|
||||
queues[name] = q.get("messages", 0)
|
||||
except Exception as e:
|
||||
queues = {"error": str(e)[:120]}
|
||||
rmq = await _rabbitmq_queues()
|
||||
queues: dict = (
|
||||
{"error": rmq["error"]}
|
||||
if "error" in rmq
|
||||
else {name: q.get("messages", 0) for name, q in rmq.items()}
|
||||
)
|
||||
|
||||
return AdminStats(
|
||||
users_total=users_total,
|
||||
@@ -423,15 +447,63 @@ async def storage_info() -> dict:
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
# ИСТОЧНИКИ ПАРСИНГА
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
async def _attach_runs(
|
||||
sources: list[ParseSource], db: AsyncSession
|
||||
) -> list[ParseSourceResponse]:
|
||||
"""Подтянуть последние прогоны одним запросом (иначе N+1 на 170+ источников)."""
|
||||
run_ids = [s.last_run_id for s in sources if s.last_run_id]
|
||||
runs: dict[int, ParseRun] = {}
|
||||
if run_ids:
|
||||
rows = (await db.execute(select(ParseRun).where(ParseRun.id.in_(run_ids)))).scalars().all()
|
||||
runs = {r.id: r for r in rows}
|
||||
|
||||
result = []
|
||||
for s in sources:
|
||||
item = ParseSourceResponse.model_validate(s)
|
||||
run = runs.get(s.last_run_id) if s.last_run_id else None
|
||||
item.last_run = ParseRunResponse.model_validate(run) if run else None
|
||||
result.append(item)
|
||||
return result
|
||||
|
||||
|
||||
async def _start_run(src: ParseSource, db: AsyncSession) -> ParseRun:
|
||||
"""Создать строку прогона и отправить таск парсера.
|
||||
|
||||
Строку создаёт API, а не воркер: при массовом запуске 170+ источников
|
||||
очередь разбирается минутами, и без неё в админке не было бы видно, что
|
||||
источник уже поставлен в очередь (шкала висела бы на прошлом прогоне).
|
||||
"""
|
||||
run = ParseRun(source_id=src.id, status="queued", stage="queued", target=src.limit, log=[])
|
||||
db.add(run)
|
||||
await db.flush()
|
||||
|
||||
src.last_status = "running"
|
||||
src.last_error = None
|
||||
src.last_run_at = datetime.utcnow()
|
||||
src.last_run_id = run.id
|
||||
await db.commit()
|
||||
await db.refresh(run)
|
||||
|
||||
celery_result = celery_app.send_task(
|
||||
"index.run_parser", args=[src.id, run.id], queue="queue.index"
|
||||
)
|
||||
run.celery_task_id = celery_result.id
|
||||
await db.commit()
|
||||
await db.refresh(run)
|
||||
return run
|
||||
|
||||
|
||||
@router.get("/sources", response_model=list[ParseSourceResponse])
|
||||
async def list_sources(db: AsyncSession = Depends(get_db)) -> list[ParseSourceResponse]:
|
||||
rows = (await db.execute(select(ParseSource).order_by(ParseSource.created_at.desc()))).scalars().all()
|
||||
return [ParseSourceResponse.model_validate(s) for s in rows]
|
||||
rows = (
|
||||
await db.execute(select(ParseSource).order_by(ParseSource.created_at.desc()))
|
||||
).scalars().all()
|
||||
return await _attach_runs(list(rows), db)
|
||||
|
||||
|
||||
@router.post("/sources", response_model=ParseSourceResponse, status_code=201)
|
||||
async def create_source(data: ParseSourceCreate, db: AsyncSession = Depends(get_db)) -> ParseSourceResponse:
|
||||
if data.source_type not in ("openalex", "cyberleninka", "arxiv"):
|
||||
if data.source_type not in SOURCE_TYPES:
|
||||
raise HTTPException(status_code=400, detail="Недопустимый тип источника")
|
||||
src = ParseSource(**data.model_dump())
|
||||
db.add(src)
|
||||
@@ -440,6 +512,44 @@ async def create_source(data: ParseSourceCreate, db: AsyncSession = Depends(get_
|
||||
return ParseSourceResponse.model_validate(src)
|
||||
|
||||
|
||||
@router.post("/sources/bulk", status_code=201)
|
||||
async def create_sources_bulk(
|
||||
data: ParseSourceBulkCreate, db: AsyncSession = Depends(get_db)
|
||||
) -> dict:
|
||||
"""Добавить пачку источников одного типа — по одному на каждый запрос-тему."""
|
||||
if data.source_type not in SOURCE_TYPES:
|
||||
raise HTTPException(status_code=400, detail="Недопустимый тип источника")
|
||||
|
||||
queries = [q.strip() for q in data.queries if q.strip()]
|
||||
if not queries:
|
||||
raise HTTPException(status_code=400, detail="Пустой список запросов")
|
||||
|
||||
prefix = data.name_prefix or data.source_type
|
||||
created: list[ParseSource] = []
|
||||
for q in queries:
|
||||
src = ParseSource(
|
||||
source_type=data.source_type,
|
||||
name=f"{prefix}:{q}"[:255],
|
||||
query=q,
|
||||
lang=data.lang,
|
||||
year_from=data.year_from,
|
||||
year_to=data.year_to,
|
||||
limit=data.limit,
|
||||
)
|
||||
db.add(src)
|
||||
created.append(src)
|
||||
await db.commit()
|
||||
|
||||
started = 0
|
||||
if data.run_now:
|
||||
for src in created:
|
||||
await db.refresh(src)
|
||||
await _start_run(src, db)
|
||||
started += 1
|
||||
|
||||
return {"created": len(created), "started": started}
|
||||
|
||||
|
||||
@router.patch("/sources/{source_id}", response_model=ParseSourceResponse)
|
||||
async def update_source(
|
||||
source_id: int, data: ParseSourceUpdate, db: AsyncSession = Depends(get_db)
|
||||
@@ -463,19 +573,386 @@ async def delete_source(source_id: int, db: AsyncSession = Depends(get_db)) -> N
|
||||
await db.commit()
|
||||
|
||||
|
||||
@router.post("/sources/{source_id}/run")
|
||||
async def run_source(source_id: int, db: AsyncSession = Depends(get_db)) -> dict:
|
||||
@router.post("/sources/run-all")
|
||||
async def run_all_sources(
|
||||
source_type: str | None = None,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> dict:
|
||||
"""Запустить заливку по всем включённым источникам (кроме уже идущих)."""
|
||||
stmt = select(ParseSource).where(ParseSource.enabled.is_(True))
|
||||
if source_type:
|
||||
stmt = stmt.where(ParseSource.source_type == source_type)
|
||||
sources = (await db.execute(stmt.order_by(ParseSource.id))).scalars().all()
|
||||
|
||||
active_ids = set(
|
||||
(
|
||||
await db.execute(
|
||||
select(ParseRun.source_id).where(ParseRun.status.in_(RUN_ACTIVE_STATUSES))
|
||||
)
|
||||
).scalars().all()
|
||||
)
|
||||
|
||||
started, skipped = 0, 0
|
||||
for src in sources:
|
||||
if src.id in active_ids:
|
||||
skipped += 1
|
||||
continue
|
||||
await _start_run(src, db)
|
||||
started += 1
|
||||
|
||||
logger.info("Массовый запуск источников: старт %d, пропущено %d", started, skipped)
|
||||
return {"started": started, "skipped_active": skipped, "total": len(sources)}
|
||||
|
||||
|
||||
@router.post("/sources/stop-all")
|
||||
async def stop_all_sources(db: AsyncSession = Depends(get_db)) -> dict:
|
||||
"""Отменить все активные прогоны (кооперативно — воркер увидит на своём тике)."""
|
||||
runs = (
|
||||
await db.execute(select(ParseRun).where(ParseRun.status.in_(RUN_ACTIVE_STATUSES)))
|
||||
).scalars().all()
|
||||
for run in runs:
|
||||
await _cancel_run(run, db)
|
||||
await db.commit()
|
||||
return {"cancelled": len(runs)}
|
||||
|
||||
|
||||
@router.post("/sources/{source_id}/run", response_model=ParseRunResponse)
|
||||
async def run_source(source_id: int, db: AsyncSession = Depends(get_db)) -> ParseRunResponse:
|
||||
src = (await db.execute(select(ParseSource).where(ParseSource.id == source_id))).scalar_one_or_none()
|
||||
if src is None:
|
||||
raise HTTPException(status_code=404, detail="Источник не найден")
|
||||
if src.last_status == "running":
|
||||
|
||||
active = (
|
||||
await db.execute(
|
||||
select(ParseRun).where(
|
||||
ParseRun.source_id == source_id, ParseRun.status.in_(RUN_ACTIVE_STATUSES)
|
||||
)
|
||||
)
|
||||
).scalars().first()
|
||||
if active is not None:
|
||||
raise HTTPException(status_code=409, detail="Источник уже парсится")
|
||||
src.last_status = "running"
|
||||
src.last_error = None
|
||||
src.last_run_at = datetime.utcnow()
|
||||
|
||||
run = await _start_run(src, db)
|
||||
return ParseRunResponse.model_validate(run)
|
||||
|
||||
|
||||
async def _cancel_run(run: ParseRun, db: AsyncSession) -> None:
|
||||
"""Отменить прогон: флаг для воркера + revoke, если таск ещё не начали.
|
||||
|
||||
Пока таск в очереди — revoke снимает его, и никто уже не переведёт прогон
|
||||
из queued, поэтому закрываем строку прямо здесь. Начатый прогон трогать
|
||||
жёстко нельзя (оборвётся посреди записи в базу) — ставим флаг, воркер
|
||||
остановится сам на ближайшем тике прогресса.
|
||||
"""
|
||||
run.cancel_requested = True
|
||||
if run.status == "queued":
|
||||
if run.celery_task_id:
|
||||
with contextlib.suppress(Exception):
|
||||
celery_app.control.revoke(run.celery_task_id)
|
||||
run.status = "cancelled"
|
||||
run.stage = "finished"
|
||||
run.finished_at = datetime.utcnow()
|
||||
src = await db.get(ParseSource, run.source_id)
|
||||
if src and src.last_run_id == run.id:
|
||||
src.last_status = "cancelled"
|
||||
|
||||
|
||||
@router.post("/sources/{source_id}/cancel")
|
||||
async def cancel_source_run(source_id: int, db: AsyncSession = Depends(get_db)) -> dict:
|
||||
runs = (
|
||||
await db.execute(
|
||||
select(ParseRun).where(
|
||||
ParseRun.source_id == source_id, ParseRun.status.in_(RUN_ACTIVE_STATUSES)
|
||||
)
|
||||
)
|
||||
).scalars().all()
|
||||
if not runs:
|
||||
raise HTTPException(status_code=404, detail="Активных прогонов нет")
|
||||
for run in runs:
|
||||
await _cancel_run(run, db)
|
||||
await db.commit()
|
||||
celery_app.send_task("index.run_parser", args=[source_id], queue="queue.index")
|
||||
return {"status": "started", "source_id": source_id}
|
||||
return {"cancelled": len(runs), "source_id": source_id}
|
||||
|
||||
|
||||
@router.get("/sources/{source_id}/runs", response_model=list[ParseRunResponse])
|
||||
async def list_source_runs(
|
||||
source_id: int,
|
||||
limit: int = Query(20, le=100),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> list[ParseRunResponse]:
|
||||
rows = (
|
||||
await db.execute(
|
||||
select(ParseRun)
|
||||
.where(ParseRun.source_id == source_id)
|
||||
.order_by(ParseRun.started_at.desc())
|
||||
.limit(limit)
|
||||
)
|
||||
).scalars().all()
|
||||
return [ParseRunResponse.model_validate(r) for r in rows]
|
||||
|
||||
|
||||
@router.get("/runs/active", response_model=list[ParseRunResponse])
|
||||
async def list_active_runs(db: AsyncSession = Depends(get_db)) -> list[ParseRunResponse]:
|
||||
"""Все прогоны в работе — для панели отладки и общей шкалы заливки."""
|
||||
rows = (
|
||||
await db.execute(
|
||||
select(ParseRun)
|
||||
.where(ParseRun.status.in_(RUN_ACTIVE_STATUSES))
|
||||
.order_by(ParseRun.started_at)
|
||||
)
|
||||
).scalars().all()
|
||||
return [ParseRunResponse.model_validate(r) for r in rows]
|
||||
|
||||
|
||||
@router.get("/runs/{run_id}", response_model=ParseRunDetail)
|
||||
async def get_run(run_id: int, db: AsyncSession = Depends(get_db)) -> ParseRunDetail:
|
||||
run = await db.get(ParseRun, run_id)
|
||||
if run is None:
|
||||
raise HTTPException(status_code=404, detail="Прогон не найден")
|
||||
detail = ParseRunDetail.model_validate(run)
|
||||
detail.log = run.log or []
|
||||
return detail
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
# ЗАГРУЗКА РАБОТ В КОРПУС
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
UPLOAD_EXTENSIONS = {".pdf", ".docx", ".txt"}
|
||||
UPLOAD_MAX_BYTES = 100 * 1024 * 1024
|
||||
UPLOAD_CONTENT_TYPES = {
|
||||
".pdf": "application/pdf",
|
||||
".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
".txt": "text/plain",
|
||||
}
|
||||
|
||||
|
||||
@router.post("/documents/upload", status_code=202)
|
||||
async def upload_documents(files: list[UploadFile]) -> dict:
|
||||
"""Залить готовые работы прямо в базу сравнения (минуя проверку и отстойник).
|
||||
|
||||
Отличие от пользовательской загрузки (/documents/check): там файл проверяют
|
||||
на плагиат, здесь он сам становится источником, с которым будут сравнивать.
|
||||
"""
|
||||
if not files:
|
||||
raise HTTPException(status_code=400, detail="Файлы не переданы")
|
||||
|
||||
accepted: list[dict] = []
|
||||
rejected: list[dict] = []
|
||||
minio = get_minio()
|
||||
|
||||
for file in files:
|
||||
name = file.filename or "без имени"
|
||||
ext = Path(name).suffix.lower()
|
||||
if ext not in UPLOAD_EXTENSIONS:
|
||||
rejected.append({"filename": name, "reason": f"формат {ext or '—'} не поддержан"})
|
||||
continue
|
||||
|
||||
data = await file.read()
|
||||
if not data:
|
||||
rejected.append({"filename": name, "reason": "пустой файл"})
|
||||
continue
|
||||
if len(data) > UPLOAD_MAX_BYTES:
|
||||
rejected.append({"filename": name, "reason": "больше 100 МБ"})
|
||||
continue
|
||||
|
||||
minio_key = f"corpus-upload/{uuid.uuid4()}{ext}"
|
||||
try:
|
||||
minio.put_object(
|
||||
bucket_name=settings.MINIO_BUCKET_DOCS,
|
||||
object_name=minio_key,
|
||||
data=io.BytesIO(data),
|
||||
length=len(data),
|
||||
content_type=UPLOAD_CONTENT_TYPES.get(ext, "application/octet-stream"),
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error("Загрузка %s в MinIO не удалась: %s", name, e)
|
||||
rejected.append({"filename": name, "reason": "ошибка сохранения в хранилище"})
|
||||
continue
|
||||
|
||||
celery_app.send_task(
|
||||
"index.ingest_upload",
|
||||
args=[minio_key, name],
|
||||
kwargs={"meta": {"title": Path(name).stem}},
|
||||
queue="queue.index",
|
||||
)
|
||||
accepted.append({"filename": name, "minio_key": minio_key, "bytes": len(data)})
|
||||
|
||||
logger.info("Админ залил в корпус: принято %d, отклонено %d", len(accepted), len(rejected))
|
||||
return {"accepted": accepted, "rejected": rejected}
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
# ОТЛАДКА
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
def _celery_snapshot() -> dict:
|
||||
"""Живое состояние воркеров через Celery inspect (блокирующий вызов)."""
|
||||
try:
|
||||
insp = celery_app.control.inspect(timeout=4)
|
||||
ping = insp.ping() or {}
|
||||
active = insp.active() or {}
|
||||
reserved = insp.reserved() or {}
|
||||
stats = insp.stats() or {}
|
||||
except Exception as e:
|
||||
return {"error": str(e)[:200], "workers": []}
|
||||
|
||||
workers = []
|
||||
for name in sorted(set(ping) | set(stats)):
|
||||
wstats = stats.get(name, {})
|
||||
workers.append({
|
||||
"name": name,
|
||||
"concurrency": (wstats.get("pool") or {}).get("max-concurrency"),
|
||||
"reserved": len(reserved.get(name, [])),
|
||||
"active": [
|
||||
{
|
||||
"name": t.get("name"),
|
||||
"id": t.get("id"),
|
||||
# args целиком не отдаём: в них бывает текст работы целиком
|
||||
"args": str(t.get("args"))[:120],
|
||||
"started_ago_s": (
|
||||
round(datetime.utcnow().timestamp() - t["time_start"])
|
||||
if t.get("time_start") else None
|
||||
),
|
||||
}
|
||||
for t in active.get(name, [])
|
||||
],
|
||||
})
|
||||
return {"workers": workers}
|
||||
|
||||
|
||||
@router.get("/debug")
|
||||
async def debug_snapshot(db: AsyncSession = Depends(get_db)) -> dict:
|
||||
"""Полный срез состояния системы для отладки заливки и проверок.
|
||||
|
||||
Один запрос вместо похода по пяти вкладкам: кто из воркеров жив и что
|
||||
именно сейчас крутит, что копится в очередях, как наполняется корпус,
|
||||
какие прогоны идут и на чём падали последние.
|
||||
"""
|
||||
# Воркеры — блокирующий Celery inspect, в пул потоков, чтобы не вешать луп
|
||||
celery_state = await run_in_threadpool(_celery_snapshot)
|
||||
|
||||
rmq = await _rabbitmq_queues()
|
||||
queues = (
|
||||
{"error": rmq["error"]}
|
||||
if "error" in rmq
|
||||
else {
|
||||
name: {
|
||||
"messages": q.get("messages", 0),
|
||||
"unacked": q.get("messages_unacknowledged", 0),
|
||||
"consumers": q.get("consumers", 0),
|
||||
}
|
||||
for name, q in sorted(rmq.items())
|
||||
}
|
||||
)
|
||||
|
||||
# ── Корпус ────────────────────────────────────────────────────────────────
|
||||
docs_total = (await db.execute(select(func.count()).select_from(Document))).scalar_one()
|
||||
docs_embedded = (
|
||||
await db.execute(
|
||||
select(func.count()).select_from(Document).where(Document.faiss_id.isnot(None))
|
||||
)
|
||||
).scalar_one()
|
||||
# count(*) по fingerprints — это десятки миллионов строк и секунды ожидания;
|
||||
# для панели отладки достаточно оценки планировщика
|
||||
fingerprints_est = (
|
||||
await db.execute(text("SELECT reltuples::bigint FROM pg_class WHERE relname='fingerprints'"))
|
||||
).scalar() or 0
|
||||
|
||||
es_docs = None
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=5) as c:
|
||||
resp = await c.get(f"{settings.ELASTICSEARCH_URL}/documents/_count")
|
||||
if resp.status_code == 200:
|
||||
es_docs = resp.json().get("count")
|
||||
except Exception:
|
||||
es_docs = None
|
||||
|
||||
# ── Источники и прогоны ───────────────────────────────────────────────────
|
||||
src_rows = (
|
||||
await db.execute(select(ParseSource.last_status, func.count()).group_by(ParseSource.last_status))
|
||||
).all()
|
||||
active_runs = (
|
||||
await db.execute(
|
||||
select(ParseRun).where(ParseRun.status.in_(RUN_ACTIVE_STATUSES)).order_by(ParseRun.id)
|
||||
)
|
||||
).scalars().all()
|
||||
recent_failed_runs = (
|
||||
await db.execute(
|
||||
select(ParseRun)
|
||||
.where(ParseRun.status.in_(("error", "partial")))
|
||||
.order_by(ParseRun.started_at.desc())
|
||||
.limit(10)
|
||||
)
|
||||
).scalars().all()
|
||||
|
||||
# Зависший прогон: числится в работе, но воркер давно не отчитывался
|
||||
stale_cutoff = datetime.utcnow() - timedelta(minutes=10)
|
||||
stale_runs = [
|
||||
r.id for r in active_runs
|
||||
if r.status == "running" and (r.heartbeat_at is None or r.heartbeat_at < stale_cutoff)
|
||||
]
|
||||
|
||||
# ── Задачи пользователей ──────────────────────────────────────────────────
|
||||
day_ago = datetime.utcnow() - timedelta(hours=24)
|
||||
tasks_24h = dict(
|
||||
(
|
||||
await db.execute(
|
||||
select(Task.status, func.count()).where(Task.created_at >= day_ago).group_by(Task.status)
|
||||
)
|
||||
).all()
|
||||
)
|
||||
failed_tasks = (
|
||||
await db.execute(
|
||||
select(Task)
|
||||
.where(Task.status == "failed")
|
||||
.order_by(Task.created_at.desc())
|
||||
.limit(10)
|
||||
)
|
||||
).scalars().all()
|
||||
|
||||
return {
|
||||
"generated_at": datetime.utcnow().isoformat(timespec="seconds"),
|
||||
"celery": celery_state,
|
||||
"queues": queues,
|
||||
"corpus": {
|
||||
"documents": docs_total,
|
||||
"documents_embedded": docs_embedded,
|
||||
"documents_without_embedding": max(docs_total - docs_embedded, 0),
|
||||
"fingerprints_estimate": int(fingerprints_est),
|
||||
"elasticsearch_documents": es_docs,
|
||||
},
|
||||
"sources": {
|
||||
"by_status": dict(src_rows),
|
||||
"active_runs": [ParseRunResponse.model_validate(r).model_dump() for r in active_runs],
|
||||
"stale_run_ids": stale_runs,
|
||||
"recent_problem_runs": [
|
||||
ParseRunResponse.model_validate(r).model_dump() for r in recent_failed_runs
|
||||
],
|
||||
},
|
||||
"tasks_24h": tasks_24h,
|
||||
"recent_failed_tasks": [
|
||||
{
|
||||
"public_id": t.public_id,
|
||||
"type": t.type,
|
||||
"error": (t.error or "")[:200],
|
||||
"created_at": t.created_at,
|
||||
}
|
||||
for t in failed_tasks
|
||||
],
|
||||
# Ключи бэкендов задаются в .env (генерируется из Infisical) и влияют на
|
||||
# поведение воркеров — при разборе «почему не так считает» нужны первыми
|
||||
"config": {
|
||||
"environment": settings.ENVIRONMENT,
|
||||
"embed_backend": os.environ.get("EMBED_BACKEND", "ollama"),
|
||||
"llm_backend": os.environ.get("LLM_BACKEND", "ollama"),
|
||||
"vector_backend": os.environ.get("VECTOR_BACKEND", "faiss"),
|
||||
"embed_model": os.environ.get("EMBED_MODEL", "—"),
|
||||
"fetch_full_text": os.environ.get("FETCH_FULL_TEXT", "false"),
|
||||
"auto_approve_submissions": os.environ.get("AUTO_APPROVE_SUBMISSIONS", "false"),
|
||||
"parser_time_budget_s": os.environ.get("PARSER_TIME_BUDGET_S", "1500"),
|
||||
"ollama_url": settings.OLLAMA_URL,
|
||||
"elasticsearch_url": settings.ELASTICSEARCH_URL,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
26
services/api/app/core/progress.py
Normal file
26
services/api/app/core/progress.py
Normal file
@@ -0,0 +1,26 @@
|
||||
"""Пересчёт состояния прогона парсинга в шкалу загрузки — чистая логика.
|
||||
|
||||
Стадии прогона несопоставимы по единицам (получено из API источника vs.
|
||||
записано в базу), а шкала в админке нужна одна. Формула живёт здесь одна на
|
||||
всех, чтобы не разъезжаться между списком источников, карточкой прогона и
|
||||
панелью отладки.
|
||||
"""
|
||||
|
||||
# Прогоны, после которых двигаться уже некуда
|
||||
TERMINAL_STATUSES = frozenset({"done", "partial", "error", "cancelled"})
|
||||
|
||||
|
||||
def run_percent(status: str, stage: str, target: int, fetched: int, processed: int) -> float:
|
||||
"""Процент готовности: 0→50% — выборка из источника, 50→100% — индексация.
|
||||
|
||||
Завершённый прогон — всегда 100%, каким бы ни был исход: шкала показывает
|
||||
«работа окончена», а исход — статус рядом с ней. Пока цель неизвестна
|
||||
(target=0), выборка не даёт прогресса — рисуем 0.
|
||||
"""
|
||||
if status in TERMINAL_STATUSES:
|
||||
return 100.0
|
||||
fetch_part = min(fetched / target, 1.0) * 50 if target > 0 else 0.0
|
||||
if stage != "index":
|
||||
return round(fetch_part, 1)
|
||||
index_part = min(processed / fetched, 1.0) * 50 if fetched > 0 else 0.0
|
||||
return round(fetch_part + index_part, 1)
|
||||
@@ -1,8 +1,8 @@
|
||||
"""Модели для админ-панели: источники парсинга, отстойник работ, сессии админа."""
|
||||
"""Модели для админ-панели: источники парсинга, прогоны, отстойник, сессии админа."""
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import JSON, ForeignKey, String, func
|
||||
from sqlalchemy import JSON, Boolean, ForeignKey, String, Text, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.database import Base
|
||||
@@ -33,12 +33,57 @@ class ParseSource(Base):
|
||||
last_error: Mapped[str | None] = mapped_column(nullable=True)
|
||||
last_run_at: Mapped[datetime | None] = mapped_column(nullable=True)
|
||||
docs_added: Mapped[int] = mapped_column(default=0)
|
||||
# Последний (пока идёт — текущий) прогон, см. ParseRun
|
||||
last_run_id: Mapped[int | None] = mapped_column(nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(server_default=func.now())
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<ParseSource id={self.id} type={self.source_type!r} name={self.name!r}>"
|
||||
|
||||
|
||||
class ParseRun(Base):
|
||||
"""Один прогон парсинга источника: счётчики прогресса + журнал событий.
|
||||
|
||||
Пишется воркером (index.run_parser) на каждом тике прогресса, читается
|
||||
админкой для шкалы загрузки и отладки — по нему видно не только «упало»,
|
||||
но и где именно: на какой стадии, сколько получено/проиндексировано,
|
||||
сколько дублей и ошибок отдельных документов.
|
||||
"""
|
||||
|
||||
__tablename__ = "parse_runs"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||
source_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("parse_sources.id", ondelete="CASCADE"), nullable=False, index=True
|
||||
)
|
||||
celery_task_id: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||
# queued / running / done / partial / error / cancelled
|
||||
status: Mapped[str] = mapped_column(String(20), default="queued", index=True)
|
||||
# queued / fetch / index / finished
|
||||
stage: Mapped[str] = mapped_column(String(20), default="queued")
|
||||
# Цель прогона (limit источника) и фактические счётчики
|
||||
target: Mapped[int] = mapped_column(default=0)
|
||||
fetched: Mapped[int] = mapped_column(default=0)
|
||||
processed: Mapped[int] = mapped_column(default=0)
|
||||
added: Mapped[int] = mapped_column(default=0)
|
||||
duplicates: Mapped[int] = mapped_column(default=0)
|
||||
# Мусор от источника (без title/ext_id) — не то же самое, что ошибка
|
||||
skipped: Mapped[int] = mapped_column(default=0)
|
||||
failed: Mapped[int] = mapped_column(default=0)
|
||||
# Кооперативная отмена: воркер сам проверяет флаг между документами
|
||||
cancel_requested: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
error: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
# [{"ts": ISO8601, "level": "info|warning|error", "msg": str}]
|
||||
log: Mapped[list | None] = mapped_column(JSON, default=list)
|
||||
started_at: Mapped[datetime] = mapped_column(server_default=func.now(), index=True)
|
||||
# Последний признак жизни: по нему видно зависший прогон (running, но тишина)
|
||||
heartbeat_at: Mapped[datetime | None] = mapped_column(nullable=True)
|
||||
finished_at: Mapped[datetime | None] = mapped_column(nullable=True)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<ParseRun id={self.id} source={self.source_id} status={self.status!r}>"
|
||||
|
||||
|
||||
class StagedWork(Base):
|
||||
"""Отстойник: проверенная пользовательская работа, ожидающая решения админа.
|
||||
|
||||
|
||||
@@ -3,7 +3,9 @@
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
from pydantic import BaseModel, Field, computed_field
|
||||
|
||||
from app.core.progress import run_percent
|
||||
|
||||
|
||||
# ─── Сессия доступа ───────────────────────────────────────────────────────────
|
||||
@@ -61,9 +63,42 @@ class AdminDocumentResponse(BaseModel):
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
# ─── Прогоны парсинга ─────────────────────────────────────────────────────────
|
||||
class ParseRunResponse(BaseModel):
|
||||
"""Состояние прогона для шкалы загрузки (без журнала — он в ParseRunDetail)."""
|
||||
|
||||
id: int
|
||||
source_id: int
|
||||
status: str
|
||||
stage: str
|
||||
target: int
|
||||
fetched: int
|
||||
processed: int
|
||||
added: int
|
||||
duplicates: int
|
||||
skipped: int
|
||||
failed: int
|
||||
cancel_requested: bool = False
|
||||
error: str | None = None
|
||||
started_at: datetime
|
||||
heartbeat_at: datetime | None = None
|
||||
finished_at: datetime | None = None
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
@computed_field # type: ignore[prop-decorator]
|
||||
@property
|
||||
def percent(self) -> float:
|
||||
return run_percent(self.status, self.stage, self.target, self.fetched, self.processed)
|
||||
|
||||
|
||||
class ParseRunDetail(ParseRunResponse):
|
||||
log: list[dict[str, Any]] = Field(default_factory=list)
|
||||
|
||||
|
||||
# ─── Источники парсинга ───────────────────────────────────────────────────────
|
||||
class ParseSourceCreate(BaseModel):
|
||||
source_type: str = Field(description="openalex / cyberleninka / arxiv")
|
||||
source_type: str = Field(description="openalex / cyberleninka / arxiv / pmc")
|
||||
name: str = Field(min_length=1, max_length=255)
|
||||
query: str | None = None
|
||||
lang: str | None = None
|
||||
@@ -98,10 +133,25 @@ class ParseSourceResponse(BaseModel):
|
||||
last_run_at: datetime | None = None
|
||||
docs_added: int
|
||||
created_at: datetime
|
||||
# Последний (или текущий) прогон — источник данных для шкалы в списке
|
||||
last_run: ParseRunResponse | None = None
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class ParseSourceBulkCreate(BaseModel):
|
||||
"""Пакетное добавление: один тип/лимит, много запросов (по строке на тему)."""
|
||||
|
||||
source_type: str = Field(description="openalex / cyberleninka / arxiv / pmc")
|
||||
queries: list[str] = Field(min_length=1, max_length=500)
|
||||
name_prefix: str = ""
|
||||
lang: str | None = None
|
||||
year_from: int | None = None
|
||||
year_to: int | None = None
|
||||
limit: int = Field(default=1000, ge=1, le=100000)
|
||||
run_now: bool = False
|
||||
|
||||
|
||||
# ─── Отстойник ────────────────────────────────────────────────────────────────
|
||||
class StagedWorkResponse(BaseModel):
|
||||
id: int
|
||||
|
||||
Reference in New Issue
Block a user