Срез 0: скелет ядра — ORM и миграция 0001 по DBML v5.13, seed, auth с dev-эмитентом, обмен через recognition_job #3

Merged
aep merged 97 commits from feat/1-slice-0-skeleton into main 2026-10-04 18:40:48 +03:00
8 changed files with 1233 additions and 39 deletions
Showing only changes of commit 9b81fa8e37 - Show all commits

View File

@ -13,7 +13,7 @@
`pytest`, `ruff check . && ruff format --check .`, `mypy src`. Из `web/`: `npm run start|build`, `npx biome check src`,
`npx tsc --noEmit`. Бутстрап — `setup/setup.sh` (pip/npm через Nexus банка).
- **Раскладка `api/src/`**: `api/endpoints` → `services` → `repositories` → `models`; DTO — `schemas/`; инфраструктура —
`core/` (config, clients, nats_callback, logger, exception_handlers, auth); `db/` — engine и сессия. Наши добавления:
`core/` (config, clients, nats, logger, exception_handlers, auth); `db/` — engine и сессия. Наши добавления:
`domain/` (чистые функции по §§ правил), `api/scripts/` (`check_dbml_vs_orm.py`, seed).
- **DI** — ручные фабрики `Depends` в `api/dependencies/`; сессия из `get_async_session`; `storage`/`nats` — из `request.app.state`.
- **Настройки** — pydantic-settings, `api/.env`, вложенность `__`, инфраструктура под `OPENBAO__*`; `DEBUG=true`

View File

@ -296,7 +296,7 @@ api/
app.py FastAPI-приложение: lifespan (S3, NATS, SMB), /healthcheck, раздача SPA
api/endpoints/ эндпоинты (me.py, dev_auth.py); router.py собирает /api/v1
api/dependencies/ зависимости FastAPI (current_user, проверка прав)
core/ config, auth (JWT), clients (S3), nats (подключение, publish, подписка), nats_callback,
core/ config, auth (JWT), clients (S3), nats (подключение, publish, подписка),
logger, exception_handlers
db/ движок и сессия (схема dkb)
models/ ORM-модели, 1:1 с DBML

View File

@ -6,12 +6,15 @@ group `arm-core`), SMB (горячие папки ИС Сканирование)
healthcheck (uptime, version, nats: ok|down|off), раздача собранного SPA.
Недоступность S3 или NATS при старте не роняет и не задерживает приложение: ошибка логируется, healthcheck показывает
состояние. При NATS_ENABLED=true lifespan запускает фоновые задачи обмена `recognition_job` — outbox-публикатор и
вахтёр сроков (`src/services/recognition_exchange.py`, design D-6) — и останавливает их до закрытия NATS.
вахтёр сроков (`src/services/recognition_exchange.py`, design D-6) — и останавливает их до закрытия NATS. Сообщения
`recognition.result` (ack и результаты сервисов) обрабатывает inbox `handle_message` того же модуля с фабрикой сессий
приложения и `NoopMapper` (маппинг результата в доменные таблицы — Срез 2).
"""
import os
from contextlib import asynccontextmanager
from datetime import UTC, datetime
from functools import partial
from http import HTTPStatus
import smbclient
@ -28,9 +31,9 @@ from src.api.router import API_PREFIX, router
from src.core.clients import S3Storage
from src.core.config import get_settings
from src.core.exception_handlers import register_exception_handlers
from src.core.nats import NatsConnection
from src.core.nats import MessageHandler, NatsConnection
from src.db.db import async_session_maker
from src.services.recognition_exchange import ExchangeTasks, handle_message
from src.services.recognition_exchange import ExchangeTasks, NoopMapper, handle_message
settings = get_settings()
@ -91,9 +94,14 @@ async def _connect_s3() -> S3Storage | None:
return S3Storage(client=s3_client, bucket_name=settings.OPENBAO.MINIO.MINIO_BUCKET)
def _result_handler() -> MessageHandler:
"""Inbox `recognition.result`: `handle_message` с фабрикой сессий приложения и `NoopMapper` (Срез 0)."""
return partial(handle_message, session_factory=async_session_maker, mapper=NoopMapper())
async def _subscribe_results(nats: NatsConnection) -> None:
"""Подписки после первого подключения к NATS: `recognition.result` в queue group `arm-core`."""
await nats.subscribe_results(handle_message)
await nats.subscribe_results(_result_handler())
def _create_nats() -> NatsConnection | None:

View File

@ -1,19 +0,0 @@
"""Обработчики сообщений NATS.
Каркас: доменный обработчик удалён. Consumer `recognition.result` (дедуп по recognition_job.result_msg_id,
маппинг JSON из S3 в document/osv_entry/financial_statement_*) реализуется в Срезе 0–2 по
docs/context/03-architecture.md. Для референса — reference-old-arm/api/src/core/nats_callback.py.
"""
from loguru import logger
from nats.aio.client import Client as NATS
from src.core.clients import S3Storage
def build_result_handler(nc: NATS, storage: S3Storage):
async def handler(msg):
logger.info("NATS message received. subject={subject}, size={size}", subject=msg.subject, size=len(msg.data))
await msg.ack()
return handler

View File

@ -22,29 +22,54 @@ SKIP LOCKED` → публикация → `UPDATE` → `COMMIT`). Блокиро
Обмен не пишет `audit_event` (рулинг R-7, design #20).
Inbox (`handle_message`) — пока заглушка: подписка в `src/core/nats.py` уже вызывает её; полноценная обработка
(ack `processing`, результат, дедуп по `result_msg_id`) — задача 24 (рулинг R-12, design #23).
Inbox (задача 24, рулинги R-12, R-22): `handle_message()` — обработчик подписки `recognition.result`
(`src/core/nats.py`, queue group `arm-core`). Одно сообщение — одна короткая транзакция без сетевого IO:
`SELECT … FOR UPDATE` строки задания (ЖДЁТ блокировку: ack, пришедший, пока публикатор держит строку до своего commit,
применяется после него, а не теряется) → проверки статуса → изменения → `COMMIT`.
- ack (`status = processing`): `published` → `processing`, `started_at`, `service_*`; повторный ack для `processing` —
тихо игнорируется; ack для другого статуса — warning, задание не меняется.
- результат (`done | needs_check | error`): дубликат определяется ТОЛЬКО по `recognition_job.result_msg_id ==
Nats-Msg-Id` (at-least-once) — без изменений; задание в финальном статусе (`FINAL_STATUSES`) — warning, без изменений;
иначе (`queued | published | processing`: потерянный ack не блокирует результат) — регистрация файла результата в
`s3_object` (`bucket_role = results`), обновление задания, `ResultMapper.map_result` (Срез 0 — `NoopMapper`; Срез 2
заменит маппингом результата в `document` / `osv_entry` / `financial_statement_*`) и commit.
- Гонка с вахтёром (`expire_deadlines` — один `UPDATE` по статусу) решается блокировкой строки: кто первым взял
строку, тот и задаёт финальный статус; второй видит финальный статус и ничего не меняет.
- Невалидное сообщение, неизвестный `job_id`, ошибка БД или mapper'а — лог без повторов; исключения наружу не выходят.
Задание без применённого результата дойдёт до `timeout` (осознанный компромисс D-6).
"""
import asyncio
import json
import uuid
from collections.abc import Awaitable, Callable, Sequence
from collections.abc import Awaitable, Callable, Mapping, Sequence
from dataclasses import dataclass
from datetime import UTC, datetime, timedelta
from typing import Any, Protocol
from loguru import logger
from nats.aio.msg import Msg
from pydantic import ValidationError
from sqlalchemy import CursorResult, and_, case, func, literal, or_, select, update
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.dialects.postgresql import insert as pg_insert
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
from src.contract import CONTRACT_VERSION, RecognitionRequest, S3Ref, request_msg_id, request_subject
from src.contract import (
CONTRACT_VERSION,
NATS_MSG_ID_HEADER,
MessageStatus,
Metrics,
RecognitionRequest,
RecognitionResultMessage,
S3Ref,
ack_msg_id,
request_msg_id,
request_subject,
result_msg_id,
)
from src.core.config import Settings
from src.core.nats import NatsPublishError
from src.models.enums import RecognitionPipeline, RecognitionStatus
from src.models.enums import RecognitionPipeline, RecognitionStatus, S3BucketRole
from src.models.files import RecognitionJob, S3Object, StoredFile
WATCHDOG_PERIOD_SECONDS = 30 # период вахтёра сроков (spec «Таймаут ожидания результата»)
@ -433,6 +458,329 @@ class ExchangeTasks:
)
async def handle_message(msg: Msg) -> None:
"""Заглушка: логирует subject и размер полученного сообщения, ничего не меняет."""
logger.info("Получено сообщение NATS. subject={subject}, size={size}", subject=msg.subject, size=len(msg.data))
# --- inbox: recognition.result ----------------------------------------------------------------------------------
# Финальные статусы для inbox (рулинг R-22, design #34): результат для них не применяется
FINAL_STATUSES: frozenset[RecognitionStatus] = frozenset(
{
RecognitionStatus.done,
RecognitionStatus.needs_check,
RecognitionStatus.error,
RecognitionStatus.timeout,
RecognitionStatus.dlq,
}
)
# Итог сервиса → статус задания
RESULT_STATUSES: Mapping[MessageStatus, RecognitionStatus] = {
MessageStatus.DONE: RecognitionStatus.done,
MessageStatus.NEEDS_CHECK: RecognitionStatus.needs_check,
MessageStatus.ERROR: RecognitionStatus.error,
}
# Счётчики значений из метрик сервиса → одноимённые колонки задания (DBML v5.7)
FIELD_COUNTERS = ("fields_total", "fields_low_confidence", "fields_unrecognized")
LOG_ID_MAX_LENGTH = 64 # job_id невалидного сообщения в логе — не длиннее (тело в лог не пишется)
class InboundMessage(Protocol):
"""То, что inbox читает из сообщения nats-py (`nats.aio.msg.Msg`)."""
@property
def data(self) -> bytes: ...
@property
def headers(self) -> Mapping[str, str] | None: ...
@property
def subject(self) -> str: ...
class ResultMapper(Protocol):
"""Точка расширения inbox (design D-6): маппинг принятого результата в доменные таблицы.
Вызывается в транзакции обработки сообщения, после обновления задания (`job` уже содержит статус и ссылку на
файл результата и может быть уточнено mapper'ом). Исключение откатывает всю транзакцию: задание не меняется и
дойдёт до `timeout`. Сетевой IO внутри — на совести реализации: транзакция держит блокировку строки задания.
Срез 0 — `NoopMapper`; Срез 2 заменит чтением файла результата и записью `document` / `osv_entry` / …
"""
async def map_result(
self, session: AsyncSession, job: RecognitionJob, message: RecognitionResultMessage
) -> None: ...
class NoopMapper:
"""Mapper Среза 0: результат только регистрируется (`s3_object`, задание), доменные таблицы не заполняются."""
async def map_result(self, session: AsyncSession, job: RecognitionJob, message: RecognitionResultMessage) -> None:
logger.debug("Маппинг результата не выполняется (NoopMapper, Срез 0). job_id={job_id}", job_id=message.job_id)
class _MapperError(Exception):
"""Исключение `ResultMapper.map_result` — отличаем от ошибок БД в логе."""
def header_msg_id(headers: Mapping[str, str] | None) -> str | None:
"""Значение заголовка `Nats-Msg-Id` без учёта регистра имени; нет заголовка или пустое значение — `None`."""
if not headers:
return None
target = NATS_MSG_ID_HEADER.lower()
for name, value in headers.items():
if name.lower() == target and value:
return value
return None
def merge_metrics(existing: Any, incoming: Mapping[str, Any] | None) -> Any:
"""Метрики сервиса поверх метрик задания: ключи ядра (`republish_count`) сохраняются, совпадающие — от сервиса.
`incoming is None` — метрики задания как есть. Не объект (SQL NULL, JSON `null` — так ORM пишет `None`) — как `{}`.
"""
if incoming is None:
return existing
base = dict(existing) if isinstance(existing, dict) else {}
return {**base, **incoming}
def _parse_message(msg: InboundMessage) -> RecognitionResultMessage | None:
"""Конверт `recognition-result-message` v1.0 или `None` (warning в лог без тела сообщения)."""
try:
data = json.loads(msg.data)
except ValueError: # JSONDecodeError и UnicodeDecodeError
data = None
if not isinstance(data, dict):
logger.warning(
"Сообщение {subject} отклонено: тело не JSON-объект. error_category=invalid_message, size={size}",
subject=msg.subject,
size=len(msg.data),
)
return None
try:
return RecognitionResultMessage.model_validate(data)
except ValidationError as exc:
raw_job_id = data.get("job_id")
job_id = raw_job_id[:LOG_ID_MAX_LENGTH] if isinstance(raw_job_id, str) else None
errors = "; ".join(
f"{'.'.join(str(part) for part in error['loc']) or '<root>'}: {error['msg']}"
for error in exc.errors(include_url=False, include_input=False, include_context=False)
)
logger.warning(
"Сообщение {subject} отклонено: не соответствует контракту recognition-result-message. "
"error_category=invalid_message, job_id={job_id}, errors={errors}",
subject=msg.subject,
job_id=job_id,
errors=errors,
)
return None
async def _lock_job(session: AsyncSession, job_id: uuid.UUID) -> RecognitionJob | None:
"""Строка задания под `FOR UPDATE` — ЖДЁТ блокировку (не SKIP LOCKED / NOWAIT): ack или результат, пришедший, пока
публикатор или вахтёр держат строку, применяется после их commit и видит их итог."""
stmt = (
select(RecognitionJob)
.where(RecognitionJob.id == job_id)
.with_for_update()
.execution_options(populate_existing=True)
)
return (await session.execute(stmt)).scalar_one_or_none()
def _apply_ack(job: RecognitionJob, message: RecognitionResultMessage, msg_id: str, now: datetime) -> None:
"""Ack (`status = processing`): `published` → `processing`; повтор для `processing` — тихо; иначе — warning."""
if job.status is RecognitionStatus.published:
job.status = RecognitionStatus.processing
job.started_at = now
job.service_name = message.service.name.value
job.service_version = message.service.version
logger.info(
"Сервис подтвердил получение запроса (ack): задание в processing. job_id={job_id}, service={service}, "
"msg_id={msg_id}",
job_id=job.id,
service=message.service.name.value,
msg_id=msg_id,
)
elif job.status is RecognitionStatus.processing:
logger.debug("Повторный ack для задания в processing, игнорируется. job_id={job_id}", job_id=job.id)
else:
logger.warning(
"Ack для задания не в статусе published: задание не изменено. error_category=unexpected_ack, "
"job_id={job_id}, status={status}, msg_id={msg_id}",
job_id=job.id,
status=job.status.value,
msg_id=msg_id,
)
async def _register_result_object(session: AsyncSession, message: RecognitionResultMessage, now: datetime) -> uuid.UUID:
"""Файл результата в реестре `s3_object` (`bucket_role = results`, `created_by = service.name`).
Уникальность `(bucket, key)`: строка с этим ключом уже есть — переиспользуется (дубль не вставляется, не падаем).
"""
assert message.result_s3 is not None
bucket, key = message.result_s3.bucket, message.result_s3.key
stmt = (
pg_insert(S3Object)
.values(
bucket_role=S3BucketRole.results,
bucket=bucket,
key=key,
created_by=message.service.name.value,
created_at=now,
)
.on_conflict_do_nothing(index_elements=[S3Object.bucket, S3Object.key])
.returning(S3Object.id)
)
inserted = (await session.execute(stmt)).scalar_one_or_none()
if inserted is not None:
return inserted
existing = (
await session.execute(select(S3Object.id).where(S3Object.bucket == bucket, S3Object.key == key))
).scalar_one()
logger.warning(
"Файл результата уже зарегистрирован в s3_object, строка переиспользована. job_id={job_id}, bucket={bucket}, "
"key={key}",
job_id=message.job_id,
bucket=bucket,
key=key,
)
return existing
async def _apply_result(
session: AsyncSession, job: RecognitionJob, message: RecognitionResultMessage, msg_id: str, now: datetime
) -> bool:
"""Результат (`done | needs_check | error`): дубликат и финальный статус — без изменений (`False`); иначе —
регистрация файла результата и обновление задания (`True`, дальше — mapper и commit)."""
if job.result_msg_id == msg_id:
logger.debug(
"Повторная доставка результата (дубликат по result_msg_id), изменений нет. job_id={job_id}, "
"msg_id={msg_id}",
job_id=job.id,
msg_id=msg_id,
)
return False
if job.status in FINAL_STATUSES:
logger.warning(
"Результат для задания в финальном статусе не применён. error_category=late_result, job_id={job_id}, "
"status={status}, result_status={result_status}, msg_id={msg_id}",
job_id=job.id,
status=job.status.value,
result_status=message.status.value,
msg_id=msg_id,
)
return False
result_s3_object_id = await _register_result_object(session, message, now) if message.result_s3 else None
job.status = RESULT_STATUSES[message.status]
job.service_name = message.service.name.value
job.service_version = message.service.version
job.finished_at = now
if message.metrics is not None:
job.metrics = merge_metrics(job.metrics, _metrics_json(message.metrics))
for name in FIELD_COUNTERS:
value = getattr(message.metrics, name)
if value is not None:
setattr(job, name, value)
job.error_code = message.error.code.value if message.error is not None else None
job.error_message = message.error.message if message.error is not None else None
job.result_schema_version = message.result_schema_version
job.result_s3_object_id = result_s3_object_id
job.result_msg_id = msg_id
return True
async def _map_result(
session: AsyncSession, job: RecognitionJob, message: RecognitionResultMessage, mapper: ResultMapper
) -> None:
"""`mapper.map_result` в транзакции сообщения; его исключение — `_MapperError` (откат всей транзакции)."""
try:
await mapper.map_result(session, job, message)
except Exception as exc:
raise _MapperError(f"ResultMapper.map_result: {type(exc).__name__}") from exc
logger.info(
"Результат распознавания принят. job_id={job_id}, status={status}, service={service}, msg_id={msg_id}, "
"error_code={error_code}",
job_id=job.id,
status=job.status.value,
service=message.service.name.value,
msg_id=job.result_msg_id,
error_code=job.error_code,
)
def _metrics_json(metrics: Metrics) -> dict[str, Any]:
return metrics.model_dump(mode="json", exclude_none=True)
async def _apply_message(
session: AsyncSession, message: RecognitionResultMessage, header_id: str | None, mapper: ResultMapper, clock: Clock
) -> None:
job = await _lock_job(session, message.job_id)
if job is None:
logger.warning(
"Сообщение recognition.result для неизвестного задания, не применено. error_category=unknown_job, "
"job_id={job_id}, status={status}",
job_id=message.job_id,
status=message.status.value,
)
return
if job.file_id != message.file_id:
logger.warning(
"Сообщение recognition.result: file_id не совпадает с заданием, не применено. "
"error_category=file_mismatch, job_id={job_id}, file_id={file_id}, message_file_id={message_file_id}",
job_id=job.id,
file_id=job.file_id,
message_file_id=message.file_id,
)
return
is_ack = message.status is MessageStatus.PROCESSING
msg_id = header_id
if msg_id is None:
msg_id = ack_msg_id(job.id, job.attempt) if is_ack else result_msg_id(job.id, job.attempt)
logger.warning(
"Сообщение recognition.result без заголовка Nats-Msg-Id: использован производный id. "
"error_category=missing_msg_id, job_id={job_id}, msg_id={msg_id}",
job_id=job.id,
msg_id=msg_id,
)
now = clock()
if is_ack:
_apply_ack(job, message, msg_id, now)
elif await _apply_result(session, job, message, msg_id, now):
await _map_result(session, job, message, mapper)
async def handle_message(
msg: InboundMessage,
*,
session_factory: async_sessionmaker[AsyncSession],
mapper: ResultMapper | None = None,
now: Clock | None = None,
) -> None:
"""Обработчик `recognition.result` (spec «Ack сервиса переводит задание в `processing`», «Inbox принимает
результат идемпотентно»; design D-6). Модель транзакции и правила — в docstring модуля.
Исключения наружу не выходят: невалидное сообщение — warning; ошибка БД или mapper'а — откат транзакции,
`logger.exception`, без повторов (задание дойдёт до `timeout`). `audit_event` не пишется (R-7).
"""
message = _parse_message(msg)
if message is None:
return
try:
async with session_factory() as session, session.begin():
await _apply_message(session, message, header_msg_id(msg.headers), mapper or NoopMapper(), now or _utcnow)
except _MapperError:
logger.exception(
"Ошибка маппинга результата распознавания, транзакция откачена; задание дойдёт до timeout. "
"error_category=mapper_error, job_id={job_id}",
job_id=message.job_id,
)
except Exception as exc:
logger.exception(
"Ошибка обработки сообщения {subject}, транзакция откачена (без повторов). error_category=inbox_error, "
"job_id={job_id}, error_type={error_type}, error={error}",
subject=msg.subject,
job_id=message.job_id,
error_type=type(exc).__name__,
error=repr(exc),
)

View File

@ -3,7 +3,7 @@
- `make_osv_entry` — минимальная цепочка FK поверх сессии (`flush`, без `commit`): откатывается фикстурой `db_session`.
- `CommittedJobs` — задания распознавания, ЗАФИКСИРОВАННЫЕ в БД (`commit`): для фоновых процессов обмена, которые
работают в собственных транзакциях и не видят чужую незафиксированную транзакцию. Всё созданное удаляется
`cleanup()` в порядке FK.
`cleanup()` в порядке FK, включая строки `s3_object` файлов результата, зарегистрированные inbox'ом.
"""
import datetime
@ -11,7 +11,7 @@ import decimal
import uuid
from typing import Any
from sqlalchemy import delete
from sqlalchemy import delete, select
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
from src.models.directories import LegalEntity
@ -92,6 +92,7 @@ class CommittedJobs:
self.job_ids: list[uuid.UUID] = []
self._file_ids: list[uuid.UUID] = []
self._s3_ids: list[uuid.UUID] = []
self._extra_s3_ids: list[uuid.UUID] = []
self._entity_ids: list[uuid.UUID] = []
async def make_queued_job( # noqa: PLR0913 - каждое поле задания задаётся тестом явно
@ -106,6 +107,8 @@ class CommittedJobs:
request_msg_id: str | None = None,
metrics: Any = None,
attempt: int = 1,
result_msg_id: str | None = None,
started_at: datetime.datetime | None = None,
) -> uuid.UUID:
"""Задание (по умолчанию `queued`, tabular, XLSX) со своей цепочкой FK; возвращает `recognition_job.id`."""
now = datetime.datetime.now(datetime.UTC)
@ -142,6 +145,8 @@ class CommittedJobs:
published_at=published_at,
request_msg_id=request_msg_id,
metrics=metrics,
result_msg_id=result_msg_id,
started_at=started_at,
)
session.add(job)
await session.flush()
@ -166,18 +171,31 @@ class CommittedJobs:
assert job is not None
return job
def track_s3_object(self, s3_object_id: uuid.UUID) -> None:
"""Запомнить строку `s3_object`, созданную тестом вне фабрики (удаляется в `cleanup()`)."""
self._extra_s3_ids.append(s3_object_id)
async def cleanup(self) -> None:
"""Удаляет всё созданное в порядке FK."""
"""Удаляет всё созданное в порядке FK (файлы результата, на которые ссылаются задания, — после заданий)."""
async with self._session_factory() as session, session.begin():
result_s3_ids: list[uuid.UUID] = []
if self.job_ids:
rows = await session.execute(
select(RecognitionJob.result_s3_object_id).where(
RecognitionJob.id.in_(self.job_ids), RecognitionJob.result_s3_object_id.is_not(None)
)
)
result_s3_ids = [row[0] for row in rows]
await session.execute(delete(RecognitionJob).where(RecognitionJob.id.in_(self.job_ids)))
if self._file_ids:
await session.execute(delete(StoredFile).where(StoredFile.id.in_(self._file_ids)))
if self._s3_ids:
await session.execute(delete(S3Object).where(S3Object.id.in_(self._s3_ids)))
s3_ids = [*self._s3_ids, *self._extra_s3_ids, *result_s3_ids]
if s3_ids:
await session.execute(delete(S3Object).where(S3Object.id.in_(s3_ids)))
if self._entity_ids:
await session.execute(delete(LegalEntity).where(LegalEntity.id.in_(self._entity_ids)))
self.job_ids.clear()
self._file_ids.clear()
self._s3_ids.clear()
self._extra_s3_ids.clear()
self._entity_ids.clear()

View File

@ -199,6 +199,31 @@ def test_create_nats_enabled_returns_connection_for_results(monkeypatch: pytest.
assert not nats.is_connected # создание не подключает: подключение — в start()
async def test_result_subscription_uses_inbox_handler_with_dependencies():
"""Подписка `recognition.result` получает `handle_message` с фабрикой сессий приложения и `NoopMapper`."""
subscribed: list[Any] = []
class _FakeConnection:
async def subscribe_results(self, handler: Any) -> None:
subscribed.append(handler)
await app_module._subscribe_results(_FakeConnection())
(handler,) = subscribed
assert handler.func is recognition_exchange.handle_message
assert handler.args == ()
assert set(handler.keywords) == {"session_factory", "mapper"}
assert handler.keywords["session_factory"] is app_module.async_session_maker
assert isinstance(handler.keywords["mapper"], recognition_exchange.NoopMapper)
def test_create_nats_subscribes_results_on_connect(monkeypatch: pytest.MonkeyPatch):
monkeypatch.setattr(app_module.settings, "NATS_ENABLED", True)
nats = app_module._create_nats()
assert nats is not None
assert nats._on_connected is app_module._subscribe_results
@pytest.mark.parametrize("secure", [True, False])
async def test_connect_s3_passes_secure_flag(monkeypatch: pytest.MonkeyPatch, secure: bool):
created: dict[str, Any] = {}

View File

@ -0,0 +1,814 @@
"""Inbox обмена `recognition_job`: `handle_message()` (spec recognition-job-exchange: «Ack сервиса переводит задание в
`processing`», «Inbox принимает результат идемпотентно»; design D-6; рулинги R-7, R-12, R-22).
Быстрые тесты (без маркера) — разбор заголовка `Nats-Msg-Id`, слияние метрик, отказ невалидным сообщениям без
обращения к БД, ошибка БД не выходит наружу, шаблонный `nats_callback` удалён.
Интеграционные — `handle_message` с фейковым Msg (`SimpleNamespace(data, headers, subject)`) на реальном PostgreSQL
(БД сессии pytest; данные фиксируются `CommittedJobs` и удаляются в teardown) и полный цикл через реальный NATS
(уникальный result-subject: общий `recognition.result` не трогаем).
"""
import asyncio
import importlib.util
import json
import time
import uuid
from collections.abc import AsyncIterator, Iterator
from datetime import UTC, datetime, timedelta
from functools import partial
from pathlib import Path
from types import SimpleNamespace
from typing import Any
import pytest
import pytest_asyncio
from jsonschema import Draft202012Validator, FormatChecker
from loguru import logger
from nats.aio.client import Client as NATS
from nats.aio.msg import Msg
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession, async_sessionmaker, create_async_engine
from src.contract import NATS_MSG_ID_HEADER, RecognitionResultMessage, ack_msg_id, result_msg_id
from src.core.config import get_settings
from src.core.nats import NatsConnection
from src.models.enums import RecognitionStatus, S3BucketRole
from src.models.files import RecognitionJob, S3Object
from src.services.recognition_exchange import (
FINAL_STATUSES,
NoopMapper,
expire_deadlines,
handle_message,
header_msg_id,
merge_metrics,
publish_pending,
)
from tests.factories import CommittedJobs
API_DIR = Path(__file__).resolve().parents[1]
MESSAGE_SCHEMA = json.loads((API_DIR / "contracts" / "recognition-result-message.schema.json").read_text("utf-8"))
MESSAGE_VALIDATOR = Draft202012Validator(MESSAGE_SCHEMA, format_checker=FormatChecker())
SERVICE = {"name": "tabular-parser", "version": "0.1.0"}
BUCKET = "arm-ka"
READY_TIMEOUT = 5.0
RACE_JOBS = 10
LOCK_HOLD_SECONDS = 0.5
# --- инфраструктура ---------------------------------------------------------------------------------------------
@pytest.fixture
def logs() -> Iterator[list[Any]]:
"""Записи loguru (уровень DEBUG и выше), выпущенные за время теста."""
records: list[Any] = []
handler_id = logger.add(lambda message: records.append(message.record), level="DEBUG")
try:
yield records
finally:
logger.remove(handler_id)
def _logged(records: list[Any], fragment: str, level: str | None = None) -> list[Any]:
return [r for r in records if fragment in r["message"] and (level is None or r["level"].name == level)]
def _result_key(file_id: uuid.UUID, job_id: uuid.UUID) -> str:
return f"results/{file_id}/{job_id}/result.json"
def envelope(job_id: uuid.UUID, file_id: uuid.UUID, status: str, **extra: Any) -> dict[str, Any]:
"""Конверт `recognition-result-message` v1.0; для done/needs_check по умолчанию — `result_s3` и версия схемы."""
body: dict[str, Any] = {
"schema_version": "1.0",
"job_id": str(job_id),
"file_id": str(file_id),
"status": status,
"service": SERVICE,
}
if status in ("done", "needs_check"):
body["result_s3"] = {"bucket": BUCKET, "key": _result_key(file_id, job_id)}
body["result_schema_version"] = "1.0"
body.update(extra)
return body
def fake_msg(body: Any, msg_id: str | None, *, header: str = NATS_MSG_ID_HEADER, validate: bool = True) -> Any:
"""Msg-подобный объект nats-py: `.data`, `.headers`, `.subject`. Тело по умолчанию проверяется JSON Schema."""
if validate:
MESSAGE_VALIDATOR.validate(body)
data = body if isinstance(body, bytes) else json.dumps(body, ensure_ascii=False).encode("utf-8")
headers = None if msg_id is None else {header: msg_id}
return SimpleNamespace(data=data, headers=headers, subject="recognition.result")
class RecordingMapper:
"""Подставной mapper: запоминает вызовы и видимое состояние задания; `fail=True` — исключение; `delay` — пауза
внутри транзакции (строка задания остаётся заблокированной)."""
def __init__(self, *, fail: bool = False, delay: float = 0) -> None:
self.fail = fail
self.delay = delay
self.calls: list[tuple[uuid.UUID, RecognitionStatus, RecognitionResultMessage]] = []
async def map_result(self, session: AsyncSession, job: RecognitionJob, message: RecognitionResultMessage) -> None:
self.calls.append((job.id, job.status, message))
if self.delay:
await asyncio.sleep(self.delay)
if self.fail:
raise RuntimeError("подставная ошибка маппинга")
@pytest.fixture
def session_factory(db_engine: AsyncEngine) -> async_sessionmaker[AsyncSession]:
return async_sessionmaker(db_engine, expire_on_commit=False)
@pytest_asyncio.fixture
async def jobs(session_factory: async_sessionmaker[AsyncSession]) -> AsyncIterator[CommittedJobs]:
committed = CommittedJobs(session_factory)
try:
yield committed
finally:
await committed.cleanup()
async def _row(session_factory: async_sessionmaker[AsyncSession], job_id: uuid.UUID) -> dict[str, Any]:
"""Снимок всех колонок строки `recognition_job`."""
async with session_factory() as session:
row = (await session.execute(select(RecognitionJob.__table__).where(RecognitionJob.id == job_id))).one()
return dict(row._mapping)
async def _s3_rows(session_factory: async_sessionmaker[AsyncSession], key: str) -> list[dict[str, Any]]:
async with session_factory() as session:
rows = await session.execute(select(S3Object.__table__).where(S3Object.key == key))
return [dict(r._mapping) for r in rows]
async def _s3_total(session_factory: async_sessionmaker[AsyncSession]) -> int:
async with session_factory() as session:
return int((await session.execute(select(func.count()).select_from(S3Object))).scalar_one())
async def _file_id(jobs: CommittedJobs, job_id: uuid.UUID) -> uuid.UUID:
return (await jobs.get(job_id)).file_id
async def _handle(session_factory: async_sessionmaker[AsyncSession], msg: Any, **kwargs: Any) -> None:
await handle_message(msg, session_factory=session_factory, **kwargs)
# --- быстрые тесты: заголовок, метрики, разбор ------------------------------------------------------------------
@pytest.mark.parametrize(
("headers", "expected"),
[
({"Nats-Msg-Id": "result:x:1"}, "result:x:1"),
({"nats-msg-id": "result:x:1"}, "result:x:1"),
({"NATS-MSG-ID": "ack:x:1", "other": "y"}, "ack:x:1"),
({"other": "y"}, None),
({"Nats-Msg-Id": ""}, None),
({}, None),
(None, None),
],
)
def test_header_msg_id_is_case_insensitive(headers: dict[str, str] | None, expected: str | None):
assert header_msg_id(headers) == expected
@pytest.mark.parametrize(
("existing", "incoming", "expected"),
[
({"republish_count": 2}, {"duration_ms": 5}, {"republish_count": 2, "duration_ms": 5}),
({"republish_count": 2, "rows": 1}, {"rows": 7}, {"republish_count": 2, "rows": 7}),
(None, {"duration_ms": 5}, {"duration_ms": 5}), # SQL NULL / JSON null (так ORM пишет None)
([None, {"x": 1}], {"duration_ms": 5}, {"duration_ms": 5}), # не объект — как пустой
({"republish_count": 1}, None, {"republish_count": 1}), # метрик сервиса нет — без изменений
(None, None, None),
],
)
def test_merge_metrics_keeps_republish_count(existing: Any, incoming: Any, expected: Any):
assert merge_metrics(existing, incoming) == expected
class _UntouchableFactory:
"""session_factory, к которому нельзя обращаться: доказывает, что сообщение отвергнуто до БД."""
def __call__(self) -> Any:
raise AssertionError("невалидное сообщение не должно открывать сессию БД")
_JOB = uuid.UUID("11111111-1111-4111-8111-111111111111")
_FILE = uuid.UUID("22222222-2222-4222-8222-222222222222")
@pytest.mark.parametrize(
"data",
[
b"{not json",
b"\xff\xfe",
b"[1, 2]",
b'"text"',
json.dumps({"schema_version": "1.0", "status": "done"}).encode(), # нет job_id
json.dumps(envelope(_JOB, _FILE, "done", schema_version="2.0")).encode(), # чужой MAJOR
json.dumps({k: v for k, v in envelope(_JOB, _FILE, "done").items() if k != "result_s3"}).encode(),
json.dumps(envelope(_JOB, _FILE, "error")).encode(), # error без поля error
json.dumps(envelope(_JOB, _FILE, "finished")).encode(), # статус вне enum
json.dumps(envelope(_JOB, _FILE, "done", service={"name": "other", "version": "1"})).encode(),
json.dumps({**envelope(_JOB, _FILE, "done"), "job_id": "not-a-uuid"}).encode(),
],
)
async def test_invalid_message_is_logged_and_does_not_touch_db(data: bytes, logs: list[Any]):
msg = SimpleNamespace(data=data, headers={NATS_MSG_ID_HEADER: "result:x:1"}, subject="recognition.result")
await handle_message(msg, session_factory=_UntouchableFactory()) # type: ignore[arg-type]
rejected = _logged(logs, "отклонено", "WARNING")
assert len(rejected) == 1
# тело целиком в лог не попадает
assert "result.json" not in rejected[0]["message"]
async def test_invalid_message_log_carries_job_id_when_readable(logs: list[Any]):
body = envelope(_JOB, _FILE, "error") # нет обязательного error
msg = fake_msg(body, "result:x:1", validate=False)
await handle_message(msg, session_factory=_UntouchableFactory()) # type: ignore[arg-type]
(record,) = _logged(logs, "отклонено", "WARNING")
assert str(_JOB) in record["message"]
async def test_database_error_is_logged_and_not_raised(logs: list[Any]):
"""Ошибка БД (сервер недоступен) — `logger.exception`, без повторов, наружу не выходит."""
engine = create_async_engine("postgresql+asyncpg://nobody:nothing@127.0.0.1:1/none")
try:
factory = async_sessionmaker(engine, expire_on_commit=False)
await handle_message(
fake_msg(envelope(_JOB, _FILE, "processing"), ack_msg_id(_JOB, 1)), session_factory=factory
)
finally:
await engine.dispose()
(record,) = _logged(logs, "транзакция откачена", "ERROR")
assert record["exception"] is not None
async def test_noop_mapper_does_nothing():
message = RecognitionResultMessage.model_validate(envelope(_JOB, _FILE, "done"))
assert await NoopMapper().map_result(None, None, message) is None # type: ignore[arg-type]
def test_final_statuses():
"""R-22 (design #34): финальные для inbox — done, needs_check, error, timeout, dlq."""
assert {s.value for s in FINAL_STATUSES} == {"done", "needs_check", "error", "timeout", "dlq"}
def test_template_nats_callback_removed():
"""R-3 (design #16): шаблонный `core/nats_callback.py` удалён и нигде в `src/` не упоминается."""
assert importlib.util.find_spec("src.core.nats_callback") is None
offenders = [p for p in (API_DIR / "src").rglob("*.py") if "nats_callback" in p.read_text(encoding="utf-8")]
assert offenders == []
# --- ack: processing ---------------------------------------------------------------------------------------------
@pytest.mark.integration
async def test_ack_moves_published_job_to_processing(session_factory, jobs: CommittedJobs, logs: list[Any]):
"""Сценарий «Сервис подтвердил»."""
job_id = await jobs.make_queued_job(status=RecognitionStatus.published, request_msg_id=f"{uuid.uuid4()}:1")
file_id = await _file_id(jobs, job_id)
before = await _row(session_factory, job_id)
moment = datetime(2026, 10, 4, 12, 0, tzinfo=UTC)
await _handle(
session_factory, fake_msg(envelope(job_id, file_id, "processing"), ack_msg_id(job_id, 1)), now=lambda: moment
)
after = await _row(session_factory, job_id)
assert after["status"] is RecognitionStatus.processing
assert after["started_at"] == moment
assert (after["service_name"], after["service_version"]) == ("tabular-parser", "0.1.0")
changed = {k for k in after if after[k] != before[k]}
assert changed == {"status", "started_at", "service_name", "service_version"}
assert _logged(logs, "ack", "INFO")
@pytest.mark.integration
async def test_repeated_ack_changes_nothing(session_factory, jobs: CommittedJobs):
job_id = await jobs.make_queued_job(status=RecognitionStatus.published)
file_id = await _file_id(jobs, job_id)
ack = fake_msg(envelope(job_id, file_id, "processing"), ack_msg_id(job_id, 1))
await _handle(session_factory, ack)
first = await _row(session_factory, job_id)
await _handle(session_factory, ack, now=lambda: datetime.now(UTC) + timedelta(minutes=1))
assert await _row(session_factory, job_id) == first
@pytest.mark.integration
@pytest.mark.parametrize(
"status",
[
RecognitionStatus.queued,
RecognitionStatus.done,
RecognitionStatus.needs_check,
RecognitionStatus.error,
RecognitionStatus.timeout,
RecognitionStatus.dlq,
],
)
async def test_ack_for_other_status_is_logged_and_ignored(
session_factory, jobs: CommittedJobs, logs: list[Any], status: RecognitionStatus
):
job_id = await jobs.make_queued_job(status=status)
file_id = await _file_id(jobs, job_id)
before = await _row(session_factory, job_id)
await _handle(session_factory, fake_msg(envelope(job_id, file_id, "processing"), ack_msg_id(job_id, 1)))
assert await _row(session_factory, job_id) == before
(record,) = _logged(logs, "задание не изменено", "WARNING")
assert status.value in record["message"]
# --- результат ---------------------------------------------------------------------------------------------------
@pytest.mark.integration
async def test_done_result_registers_s3_object_and_updates_job(session_factory, jobs: CommittedJobs):
"""Сценарий «Успешный результат»: s3_object (results), result_msg_id, finished_at, метрики слиты."""
job_id = await jobs.make_queued_job(status=RecognitionStatus.processing, metrics={"republish_count": 1})
file_id = await _file_id(jobs, job_id)
moment = datetime(2026, 10, 4, 12, 30, tzinfo=UTC)
metrics = {"duration_ms": 1234, "rows": 50, "fields_total": 300, "fields_low_confidence": 4,
"fields_unrecognized": 2, "warnings": ["лист без шапки"]} # fmt: skip
body = envelope(job_id, file_id, "done", metrics=metrics, result_schema_version="1.1")
before_total = await _s3_total(session_factory)
await _handle(session_factory, fake_msg(body, result_msg_id(job_id, 1)), now=lambda: moment)
job = await _row(session_factory, job_id)
(s3,) = await _s3_rows(session_factory, _result_key(file_id, job_id))
assert await _s3_total(session_factory) == before_total + 1
assert job["status"] is RecognitionStatus.done
assert job["result_msg_id"] == f"result:{job_id}:1"
assert job["result_s3_object_id"] == s3["id"]
assert job["finished_at"] == moment
assert job["result_schema_version"] == "1.1"
assert (job["service_name"], job["service_version"]) == ("tabular-parser", "0.1.0")
assert job["metrics"] == {"republish_count": 1, **metrics}
assert (job["fields_total"], job["fields_low_confidence"], job["fields_unrecognized"]) == (300, 4, 2)
assert (job["error_code"], job["error_message"]) == (None, None)
assert s3["bucket_role"] is S3BucketRole.results
assert (s3["bucket"], s3["created_by"], s3["created_at"]) == (BUCKET, "tabular-parser", moment)
@pytest.mark.integration
async def test_needs_check_result(session_factory, jobs: CommittedJobs):
job_id = await jobs.make_queued_job(status=RecognitionStatus.processing)
file_id = await _file_id(jobs, job_id)
await _handle(session_factory, fake_msg(envelope(job_id, file_id, "needs_check"), result_msg_id(job_id, 1)))
job = await _row(session_factory, job_id)
assert job["status"] is RecognitionStatus.needs_check
assert job["result_s3_object_id"] is not None
assert job["metrics"] is None # метрик не было ни у задания, ни у сервиса: колонка не трогается
@pytest.mark.integration
async def test_redelivery_changes_nothing(session_factory, jobs: CommittedJobs, logs: list[Any]):
"""Сценарий «Повторная доставка»: ни recognition_job, ни s3_object не меняются; mapper не вызывается."""
job_id = await jobs.make_queued_job(status=RecognitionStatus.processing)
file_id = await _file_id(jobs, job_id)
msg = fake_msg(envelope(job_id, file_id, "done", metrics={"rows": 3}), result_msg_id(job_id, 1))
mapper = RecordingMapper()
await _handle(session_factory, msg, mapper=mapper)
job_before = await _row(session_factory, job_id)
s3_before = await _s3_rows(session_factory, _result_key(file_id, job_id))
total_before = await _s3_total(session_factory)
await _handle(session_factory, msg, mapper=mapper, now=lambda: datetime.now(UTC) + timedelta(hours=1))
assert await _row(session_factory, job_id) == job_before
assert await _s3_rows(session_factory, _result_key(file_id, job_id)) == s3_before
assert await _s3_total(session_factory) == total_before
assert len(mapper.calls) == 1
assert _logged(logs, "дубликат", "DEBUG")
assert not _logged(logs, "финальном статусе")
@pytest.mark.integration
async def test_service_error_without_result_s3(session_factory, jobs: CommittedJobs):
"""Сценарий «Ошибка сервиса»: NOT_IMPLEMENTED без result_s3."""
job_id = await jobs.make_queued_job(status=RecognitionStatus.processing)
file_id = await _file_id(jobs, job_id)
total_before = await _s3_total(session_factory)
body = envelope(job_id, file_id, "error", error={"code": "NOT_IMPLEMENTED", "message": "разбор ОСВ — Срез 1"})
await _handle(session_factory, fake_msg(body, result_msg_id(job_id, 1)))
job = await _row(session_factory, job_id)
assert job["status"] is RecognitionStatus.error
assert (job["error_code"], job["error_message"]) == ("NOT_IMPLEMENTED", "разбор ОСВ — Срез 1")
assert job["result_s3_object_id"] is None
assert job["result_msg_id"] == f"result:{job_id}:1"
assert job["finished_at"] is not None
assert job["result_schema_version"] is None
assert await _s3_total(session_factory) == total_before
@pytest.mark.integration
@pytest.mark.parametrize(
"status",
[
RecognitionStatus.timeout,
RecognitionStatus.dlq,
RecognitionStatus.error,
RecognitionStatus.done,
RecognitionStatus.needs_check,
],
)
async def test_result_for_final_status_is_logged_and_ignored(
session_factory, jobs: CommittedJobs, logs: list[Any], status: RecognitionStatus
):
"""Сценарий «Результат пришёл после таймаута» (и прочие финальные статусы, R-22): задание не меняется."""
other = f"result:{uuid.uuid4()}:9" if status in (RecognitionStatus.done, RecognitionStatus.needs_check) else None
job_id = await jobs.make_queued_job(status=status, result_msg_id=other)
file_id = await _file_id(jobs, job_id)
before = await _row(session_factory, job_id)
total_before = await _s3_total(session_factory)
mapper = RecordingMapper()
await _handle(session_factory, fake_msg(envelope(job_id, file_id, "done"), result_msg_id(job_id, 1)), mapper=mapper)
assert await _row(session_factory, job_id) == before
assert await _s3_total(session_factory) == total_before
assert mapper.calls == []
(record,) = _logged(logs, "финальном статусе", "WARNING")
assert status.value in record["message"]
@pytest.mark.integration
async def test_unknown_job_is_logged_and_creates_nothing(session_factory, logs: list[Any]):
job_id, file_id = uuid.uuid4(), uuid.uuid4()
total_before = await _s3_total(session_factory)
await _handle(session_factory, fake_msg(envelope(job_id, file_id, "done"), result_msg_id(job_id, 1)))
assert await _s3_total(session_factory) == total_before
assert await _s3_rows(session_factory, _result_key(file_id, job_id)) == []
(record,) = _logged(logs, "неизвестного задания", "WARNING")
assert str(job_id) in record["message"]
@pytest.mark.integration
async def test_file_id_mismatch_is_logged_and_ignored(session_factory, jobs: CommittedJobs, logs: list[Any]):
job_id = await jobs.make_queued_job(status=RecognitionStatus.processing)
before = await _row(session_factory, job_id)
total_before = await _s3_total(session_factory)
await _handle(session_factory, fake_msg(envelope(job_id, uuid.uuid4(), "done"), result_msg_id(job_id, 1)))
assert await _row(session_factory, job_id) == before
assert await _s3_total(session_factory) == total_before
assert _logged(logs, "file_id", "WARNING")
@pytest.mark.integration
@pytest.mark.parametrize("status", [RecognitionStatus.published, RecognitionStatus.queued])
async def test_result_without_ack_is_accepted(session_factory, jobs: CommittedJobs, status: RecognitionStatus):
"""R-22: потерянный ack не блокирует результат; `started_at` остаётся пустым (начало обработки неизвестно)."""
job_id = await jobs.make_queued_job(status=status)
file_id = await _file_id(jobs, job_id)
await _handle(session_factory, fake_msg(envelope(job_id, file_id, "done"), result_msg_id(job_id, 1)))
job = await _row(session_factory, job_id)
assert job["status"] is RecognitionStatus.done
assert job["started_at"] is None
assert job["service_name"] == "tabular-parser"
@pytest.mark.integration
async def test_msg_id_header_case_insensitive(session_factory, jobs: CommittedJobs):
job_id = await jobs.make_queued_job(status=RecognitionStatus.processing)
file_id = await _file_id(jobs, job_id)
await _handle(
session_factory, fake_msg(envelope(job_id, file_id, "done"), result_msg_id(job_id, 1), header="nats-msg-id")
)
assert (await _row(session_factory, job_id))["result_msg_id"] == f"result:{job_id}:1"
@pytest.mark.integration
async def test_missing_msg_id_header_uses_derived_id(session_factory, jobs: CommittedJobs, logs: list[Any]):
"""Нет `Nats-Msg-Id` — предупреждение и производный `result:<job_id>:<attempt>`; повтор — дубликат."""
job_id = await jobs.make_queued_job(status=RecognitionStatus.processing, attempt=2)
file_id = await _file_id(jobs, job_id)
msg = fake_msg(envelope(job_id, file_id, "done"), None)
await _handle(session_factory, msg)
job = await _row(session_factory, job_id)
await _handle(session_factory, msg)
assert job["status"] is RecognitionStatus.done
assert job["result_msg_id"] == f"result:{job_id}:2"
assert await _row(session_factory, job_id) == job
assert len(_logged(logs, "без заголовка Nats-Msg-Id", "WARNING")) == 2 # noqa: PLR2004 - оба сообщения
@pytest.mark.integration
async def test_existing_s3_object_is_reused(session_factory, jobs: CommittedJobs):
"""Ключ результата уже зарегистрирован — строка переиспользуется, дубль (bucket, key) не вставляется."""
job_id = await jobs.make_queued_job(status=RecognitionStatus.processing)
file_id = await _file_id(jobs, job_id)
async with session_factory() as session, session.begin():
existing = S3Object(
bucket_role=S3BucketRole.results,
bucket=BUCKET,
key=_result_key(file_id, job_id),
created_by="tabular-parser",
created_at=datetime.now(UTC),
)
session.add(existing)
await session.flush()
existing_id = existing.id
jobs.track_s3_object(existing_id)
await _handle(session_factory, fake_msg(envelope(job_id, file_id, "done"), result_msg_id(job_id, 1)))
assert (await _row(session_factory, job_id))["result_s3_object_id"] == existing_id
assert len(await _s3_rows(session_factory, _result_key(file_id, job_id))) == 1
# --- mapper ------------------------------------------------------------------------------------------------------
@pytest.mark.integration
async def test_mapper_sees_job_and_message(session_factory, jobs: CommittedJobs):
job_id = await jobs.make_queued_job(status=RecognitionStatus.processing)
file_id = await _file_id(jobs, job_id)
mapper = RecordingMapper()
await _handle(session_factory, fake_msg(envelope(job_id, file_id, "done"), result_msg_id(job_id, 1)), mapper=mapper)
((seen_job_id, seen_status, message),) = mapper.calls
assert seen_job_id == job_id
assert seen_status is RecognitionStatus.done # mapper видит уже обновлённое задание (может его уточнить)
assert message.job_id == job_id
assert message.result_s3 is not None
assert message.result_s3.key == _result_key(file_id, job_id)
@pytest.mark.integration
async def test_mapper_error_rolls_back_whole_transaction(session_factory, jobs: CommittedJobs, logs: list[Any]):
"""Исключение mapper'а — откат всего: задание не изменено, s3_object не создан (дойдёт до timeout, D-6)."""
job_id = await jobs.make_queued_job(status=RecognitionStatus.processing)
file_id = await _file_id(jobs, job_id)
before = await _row(session_factory, job_id)
total_before = await _s3_total(session_factory)
msg = fake_msg(envelope(job_id, file_id, "done"), result_msg_id(job_id, 1))
await _handle(session_factory, msg, mapper=RecordingMapper(fail=True))
assert await _row(session_factory, job_id) == before
assert await _s3_total(session_factory) == total_before
(record,) = _logged(logs, "Ошибка маппинга", "ERROR")
assert record["exception"] is not None
# --- гонки --------------------------------------------------------------------------------------------------------
@pytest.mark.integration
async def test_concurrent_same_message_applied_once(session_factory, jobs: CommittedJobs, logs: list[Any]):
job_id = await jobs.make_queued_job(status=RecognitionStatus.processing)
file_id = await _file_id(jobs, job_id)
msg = fake_msg(envelope(job_id, file_id, "done"), result_msg_id(job_id, 1))
mapper = RecordingMapper(delay=LOCK_HOLD_SECONDS) # первый держит строку, второй ждёт его commit
await asyncio.gather(*[_handle(session_factory, msg, mapper=mapper) for _ in range(2)])
assert len(await _s3_rows(session_factory, _result_key(file_id, job_id))) == 1
assert (await _row(session_factory, job_id))["result_msg_id"] == f"result:{job_id}:1"
assert len(mapper.calls) == 1
assert len(_logged(logs, "дубликат", "DEBUG")) == 1
assert not _logged(logs, "неизвестного задания")
class _SlowNats:
"""Подставной NATS: публикация «висит» `delay` секунд, пока публикатор держит блокировку строки задания."""
def __init__(self, delay: float) -> None:
self.delay = delay
self.entered = asyncio.Event()
@property
def is_connected(self) -> bool:
return True
async def publish(self, subject: str, payload: bytes, msg_id: str) -> None:
self.entered.set()
await asyncio.sleep(self.delay)
@pytest.mark.integration
async def test_ack_waits_for_publisher_lock(session_factory, jobs: CommittedJobs):
"""Ack пришёл, пока публикатор держит строку (до его commit): обработчик ждёт блокировку и применяет ack после."""
job_id = await jobs.make_queued_job()
file_id = await _file_id(jobs, job_id)
nats = _SlowNats(LOCK_HOLD_SECONDS)
publisher = asyncio.create_task(
publish_pending(session_factory, nats, ack_timeout=timedelta(seconds=30), max_republish=3, batch_size=1)
)
await asyncio.wait_for(nats.entered.wait(), 5)
started = time.monotonic()
await _handle(session_factory, fake_msg(envelope(job_id, file_id, "processing"), ack_msg_id(job_id, 1)))
waited = time.monotonic() - started
report = await publisher
job = await _row(session_factory, job_id)
assert report.published == 1
assert waited >= LOCK_HOLD_SECONDS / 2 # ждал блокировку, а не увидел queued
assert job["status"] is RecognitionStatus.processing
assert job["published_at"] is not None
assert job["started_at"] is not None
@pytest.mark.integration
async def test_result_vs_watchdog_is_consistent(session_factory, jobs: CommittedJobs):
"""Результат и вахтёр на заданиях с прошедшим сроком одновременно: каждое — либо done (с s3_object), либо
timeout (без результата), но не смесь. Порядок решает блокировка строки: кто первый, тот и финальный."""
past = datetime.now(UTC) - timedelta(minutes=1)
job_ids = [
await jobs.make_queued_job(status=RecognitionStatus.processing, reply_deadline=past) for _ in range(RACE_JOBS)
]
messages = [
fake_msg(envelope(job_id, await _file_id(jobs, job_id), "done"), result_msg_id(job_id, 1)) for job_id in job_ids
]
await asyncio.gather(expire_deadlines(session_factory), *[_handle(session_factory, m) for m in messages])
for job_id in job_ids:
job = await _row(session_factory, job_id)
s3 = await _s3_rows(session_factory, _result_key(job["file_id"], job_id))
if job["status"] is RecognitionStatus.done:
assert job["error_code"] is None
assert job["result_msg_id"] == f"result:{job_id}:1"
assert [r["id"] for r in s3] == [job["result_s3_object_id"]]
else:
assert job["status"] is RecognitionStatus.timeout
assert job["error_code"] == "TIMEOUT"
assert job["result_msg_id"] is None
assert job["result_s3_object_id"] is None
assert s3 == []
@pytest.mark.integration
async def test_watchdog_waits_for_result_transaction(session_factory, jobs: CommittedJobs):
"""Вахтёр запущен, пока обработчик результата держит строку: его UPDATE ждёт commit, перепроверяет статус на новой
версии строки (`done`) и задание не трогает."""
past = datetime.now(UTC) - timedelta(minutes=1)
job_id = await jobs.make_queued_job(status=RecognitionStatus.processing, reply_deadline=past)
mapper = RecordingMapper(delay=LOCK_HOLD_SECONDS)
msg = fake_msg(envelope(job_id, await _file_id(jobs, job_id), "done"), result_msg_id(job_id, 1))
handler = asyncio.create_task(_handle(session_factory, msg, mapper=mapper))
async def locked() -> None: # обработчик внутри транзакции, строка заблокирована
while not mapper.calls:
await asyncio.sleep(0.01)
await asyncio.wait_for(locked(), 5)
expired = await expire_deadlines(session_factory)
await handler
assert expired == 0
job = await _row(session_factory, job_id)
assert job["status"] is RecognitionStatus.done
assert job["error_code"] is None
@pytest.mark.integration
async def test_watchdog_after_result_and_result_after_watchdog(session_factory, jobs: CommittedJobs):
past = datetime.now(UTC) - timedelta(minutes=1)
first = await jobs.make_queued_job(status=RecognitionStatus.processing, reply_deadline=past)
second = await jobs.make_queued_job(status=RecognitionStatus.processing, reply_deadline=past)
await _handle(
session_factory, fake_msg(envelope(first, await _file_id(jobs, first), "done"), result_msg_id(first, 1))
)
await expire_deadlines(session_factory)
await _handle(
session_factory, fake_msg(envelope(second, await _file_id(jobs, second), "done"), result_msg_id(second, 1))
)
assert (await _row(session_factory, first))["status"] is RecognitionStatus.done
assert (await _row(session_factory, second))["status"] is RecognitionStatus.timeout
# --- e2e: реальный NATS -------------------------------------------------------------------------------------------
async def _wait_until(predicate: Any, timeout: float = 10.0) -> None:
deadline = time.monotonic() + timeout
while not await predicate():
if time.monotonic() > deadline:
raise AssertionError("условие не выполнено за отведённое время")
await asyncio.sleep(0.05)
async def _wait_status(
session_factory: async_sessionmaker[AsyncSession], job_id: uuid.UUID, status: RecognitionStatus
) -> dict[str, Any]:
async def reached() -> bool:
return (await _row(session_factory, job_id))["status"] is status
await _wait_until(reached)
return await _row(session_factory, job_id)
class _FakeService:
"""«Сервис распознавания» поверх голого nats-py: принимает запросы своего задания, публикует конверты."""
def __init__(self, job_id: uuid.UUID, result_subject: str) -> None:
self.job_id = job_id
self.result_subject = result_subject
self.requests: list[Msg] = []
self._nc = NATS()
async def start(self, url: str) -> None:
await self._nc.connect(url, connect_timeout=2, allow_reconnect=False)
async def collect(msg: Msg) -> None:
if json.loads(msg.data).get("job_id") == str(self.job_id):
self.requests.append(msg)
await self._nc.subscribe("recognition.tabular.request", cb=collect)
await self._nc.flush()
async def send(self, body: dict[str, Any], msg_id: str) -> None:
MESSAGE_VALIDATOR.validate(body)
await self._nc.publish(self.result_subject, json.dumps(body).encode(), headers={NATS_MSG_ID_HEADER: msg_id})
await self._nc.flush()
async def stop(self) -> None:
await self._nc.close()
@pytest.mark.integration
async def test_full_cycle_over_real_nats(session_factory, jobs: CommittedJobs, logs: list[Any]):
"""queued → published (outbox) → processing (ack) → done + s3_object (результат) → повтор без изменений."""
settings = get_settings().OPENBAO.NATS
result_subject = f"test.result.{uuid.uuid4().hex}"
handler = partial(handle_message, session_factory=session_factory, mapper=NoopMapper())
async def subscribe(conn: NatsConnection) -> None:
await conn.subscribe_results(handler)
core = NatsConnection(settings, on_connected=subscribe, result_subject=result_subject)
job_id = await jobs.make_queued_job()
file_id = await _file_id(jobs, job_id)
service = _FakeService(job_id, result_subject)
await core.start()
try:
if not await core.wait_ready(timeout=READY_TIMEOUT):
pytest.fail(f"NATS недоступен ({settings.NATS_URL}, .env.test)", pytrace=False)
await service.start(settings.NATS_URL)
async def published() -> bool:
await publish_pending(session_factory, core, ack_timeout=timedelta(seconds=30), max_republish=3)
return bool(service.requests)
await _wait_until(published)
await _wait_status(session_factory, job_id, RecognitionStatus.published)
assert service.requests[0].headers == {NATS_MSG_ID_HEADER: f"{job_id}:1"}
await service.send(envelope(job_id, file_id, "processing"), ack_msg_id(job_id, 1))
job = await _wait_status(session_factory, job_id, RecognitionStatus.processing)
assert job["started_at"] is not None
assert (job["service_name"], job["service_version"]) == ("tabular-parser", "0.1.0")
result = envelope(job_id, file_id, "done", metrics={"duration_ms": 10})
await service.send(result, result_msg_id(job_id, 1))
job = await _wait_status(session_factory, job_id, RecognitionStatus.done)
(s3,) = await _s3_rows(session_factory, _result_key(file_id, job_id))
assert (job["result_s3_object_id"], job["result_msg_id"]) == (s3["id"], f"result:{job_id}:1")
assert job["finished_at"] is not None
assert s3["bucket_role"] is S3BucketRole.results
await service.send(result, result_msg_id(job_id, 1))
async def duplicate_seen() -> bool:
return bool(_logged(logs, "дубликат", "DEBUG"))
await _wait_until(duplicate_seen)
assert await _row(session_factory, job_id) == job
assert await _s3_rows(session_factory, _result_key(file_id, job_id)) == [s3]
finally:
await service.stop()
await core.stop()