Files
anti-plagiarism/services/worker-indexer/app/db.py
jze9 12eb954838
All checks were successful
Deploy / test (push) Successful in 2m51s
Deploy / deploy (push) Successful in 15s
fix(indexer): возвращать месячную квоту при провале извлечения файла
Живой репорт: юзер получил "Превышен месячный лимит проверок (тариф 'free'):
1/1" сразу после ЕДИНСТВЕННОЙ попытки — а та попытка провалилась ещё на
"файл не является PDF" (см. предыдущий коммит df2dfcc). Причина: API списывает
месячную квоту plagiarism синхронно при ЗАГРУЗКЕ файла (check_and_increment_
limit в documents.py), ДО того как воркер вообще попытается его распарсить —
реальной проверки не было, а квота уже списана навсегда (сброс только в
следующем месяце). У free-тарифа лимит 1/мес — то есть один неверный формат
файла сжигал единственную попытку целиком.

Плюс сопутствующая неэффективность: extract_and_check ретраил (3 попытки,
60с задержка) даже детерминированные ошибки формата — на 2-й и 3-й попытке
результат будет тем же, ретрай только откладывает финальный фидбек юзеру
на пару минут без всякого смысла.

Фикс:
- ValueError (битый файл/пустой текст) теперь ловится ДО общего Exception:
  без ретрая (не поможет), с возвратом квоты (реальной проверки не было).
- db.refund_plagiarism_quota(): декремент того же Redis-ключа
  rl:{user_id}:plagiarism:{YYYY-MM}, что инкрементит api/rate_limiter.py —
  тот же формат ключа, декремент виден мгновенно и там, и там (общий Redis).
- Транзиентные ошибки (сеть/MinIO/БД) — поведение прежнее (ретрай, без
  возврата квоты, т.к. задача может ещё успешно завершиться).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-24 21:32:02 +05:00

114 lines
4.0 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Синхронное подключение к PostgreSQL, MinIO и Redis для индексер-воркера."""
import logging
from collections.abc import Generator
from contextlib import contextmanager
from datetime import UTC, datetime
import redis
from minio import Minio
from sqlalchemy import create_engine
from sqlalchemy.orm import Session, sessionmaker
from app.config import settings
logger = logging.getLogger(__name__)
# Синхронный движок SQLAlchemy
engine = create_engine(
settings.database_url_sync,
pool_size=5,
max_overflow=10,
pool_pre_ping=True,
pool_recycle=3600,
)
SessionLocal = sessionmaker(bind=engine, autocommit=False, autoflush=False)
@contextmanager
def db_session() -> Generator[Session, None, None]:
"""Контекстный менеджер для сессии БД."""
session = SessionLocal()
try:
yield session
session.commit()
except Exception:
session.rollback()
raise
finally:
session.close()
_minio_client: Minio | None = None
def get_minio() -> Minio:
"""Получить или создать MinIO клиент."""
global _minio_client
if _minio_client is None:
_minio_client = Minio(
settings.MINIO_ENDPOINT,
access_key=settings.MINIO_ACCESS_KEY,
secret_key=settings.MINIO_SECRET_KEY,
secure=False,
)
return _minio_client
def update_task_status(task_id: str, status: str, error: str | None = None) -> None:
"""Обновить статус задачи в БД."""
from app.models import Task
with db_session() as session:
task = session.get(Task, task_id)
if task:
task.status = status
if error:
task.error = error
session.commit()
_redis_client: redis.Redis | None = None
def get_redis() -> redis.Redis:
"""Получить или создать sync Redis клиент (тот же Redis, что и у API —
там считаются rate-limit'ы, ключи вида rl:{user_id}:{action}:{period})."""
global _redis_client
if _redis_client is None:
_redis_client = redis.Redis.from_url(settings.REDIS_URL, decode_responses=True)
return _redis_client
def refund_plagiarism_quota(task_id: str) -> None:
"""Вернуть месячную квоту проверок плагиата владельцу задачи.
Вызывается, когда задача провалилась ДО начала реальной проверки (битый
файл, не тот формат) — API списывает квоту синхронно при загрузке файла,
заранее, не дожидаясь, распознается ли он вообще. Без возврата пользователь
терял бы месячный лимит (у free — 1 в месяц) за одну неудачную попытку с
неправильным файлом. Ключ и формат периода — как в
api/app/core/rate_limiter.py (rl:{user_id}:plagiarism:{YYYY-MM}), чтобы
декремент попадал в тот же счётчик, что инкрементил API.
"""
from app.models import Task
try:
with db_session() as session:
task = session.get(Task, task_id)
if task is None:
return
user_id = task.user_id
period = datetime.now(UTC).strftime("%Y-%m")
key = f"rl:{user_id}:plagiarism:{period}"
r = get_redis()
current = r.get(key)
if current and int(current) > 0:
r.decr(key)
logger.info(f"Квота plagiarism возвращена пользователю {user_id} (задача {task_id!r})")
except Exception as e:
# Невозврат квоты — не повод валить обработку ошибки задачи
logger.warning(f"Не удалось вернуть квоту для задачи {task_id!r}: {e}")