NATS: подписка, публикация и /healthcheck.subjects по настройкам; интеграционные тесты на своих subject'ах (#32)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-10-13 12:19:11 +04:00
parent f1f7d784a5
commit 192500e9f9
10 changed files with 192 additions and 64 deletions

View File

@ -298,7 +298,10 @@ pydantic-валидацией; битый файл (код не из 4–5 ци
| `OPENBAO__MINIO__MINIO_BUCKET` | — (обязательна) | единственный бакет сервиса; запрос с другим `s3.bucket` -> `FILE_READ_ERROR` | | `OPENBAO__MINIO__MINIO_BUCKET` | — (обязательна) | единственный бакет сервиса; запрос с другим `s3.bucket` -> `FILE_READ_ERROR` |
| `OPENBAO__MINIO__MINIO_SECURE` | `true` | https; для MinIO из compose — `false` | | `OPENBAO__MINIO__MINIO_SECURE` | `true` | https; для MinIO из compose — `false` |
| `OPENBAO__NATS__NATS_URL` | — (обязательна) | адрес NATS | | `OPENBAO__NATS__NATS_URL` | — (обязательна) | адрес NATS |
| `OPENBAO__NATS__NATS_SEED`, `OPENBAO__NATS__NATS_JWT` | пусто | nkeys/JWT платформы; оба пусты — без учётных данных; задан один — ошибка конфигурации | | `OPENBAO__NATS__NATS_SEED`, `OPENBAO__NATS__NATS_JWT` | пусто | nkeys/JWT платформы (`SecretStr`, в логи и `/healthcheck` не попадают); оба пусты — без учётных данных; задан один — ошибка конфигурации |
| `OPENBAO__NATS__NATS_IMAGE_REQUEST_SUBJECT` | `files.img_uploaded` | subject запроса, на который подписан сервис; у ядра окружения то же значение |
| `OPENBAO__NATS__NATS_RESULT_SUBJECT` | `files.tabular_parsed` | subject, в который сервис публикует ack и итог |
| `OPENBAO__NATS__NATS_TABULAR_REQUEST_SUBJECT` | `files.tabular_uploaded` | запрос `tabular` (сервис его не слушает; значение участвует в проверке: три имени непусты, без пробелов, `*`, `>` и попарно различны, иначе сервис не стартует) |
| `OPENBAO__SMB__SMB_HOST`, `OPENBAO__SMB__SMB_PATH`, `OPENBAO__SMB__SMB_LOGIN`, `OPENBAO__SMB__SMB_PASSWORD` | пусто | шара горячих папок; обязательны только при `SCAN_HOTFOLDER_MODE=smb`; `SMB_PATH` — `share/dir` или полный UNC | | `OPENBAO__SMB__SMB_HOST`, `OPENBAO__SMB__SMB_PATH`, `OPENBAO__SMB__SMB_LOGIN`, `OPENBAO__SMB__SMB_PASSWORD` | пусто | шара горячих папок; обязательны только при `SCAN_HOTFOLDER_MODE=smb`; `SMB_PATH` — `share/dir` или полный UNC |
| `NATS_ENABLED` | `true` | `false` — воркер не запускается, подключения к NATS нет (`/healthcheck.nats = disabled`) | | `NATS_ENABLED` | `true` | `false` — воркер не запускается, подключения к NATS нет (`/healthcheck.nats = disabled`) |
| `PUBLISH_RETRIES` | `3` | повторы публикации ack/результата после первой попытки (всего до 4) | | `PUBLISH_RETRIES` | `3` | повторы публикации ack/результата после первой попытки (всего до 4) |
@ -362,8 +365,8 @@ MinIO и NATS); те же значения подхватываются из `EN
Схемы и примеры — `../docs/contracts/`; их байт-в-байт копии лежат в `api/contracts/` (идентичность проверяет unit-тест Схемы и примеры — `../docs/contracts/`; их байт-в-байт копии лежат в `api/contracts/` (идентичность проверяет unit-тест
`api/tests/unit/test_contract.py`, входит в `make check`), pydantic-модели — `api/src/contract/`. `api/tests/unit/test_contract.py`, входит в `make check`), pydantic-модели — `api/src/contract/`.
- Запрос: subject `files.img_uploaded`, подписка в queue group `scan-gateway`, `Nats-Msg-Id = <job_id>:<attempt>`. - Запрос: subject `files.img_uploaded` (по умолчанию; `OPENBAO__NATS__NATS_IMAGE_REQUEST_SUBJECT`), подписка в queue group `scan-gateway`, `Nats-Msg-Id = <job_id>:<attempt>`.
- Ответы — subject `files.tabular_parsed`: ack `status=processing`, `Nats-Msg-Id = ack:<job_id>:<attempt>`; итог - Ответы — subject `files.tabular_parsed` (по умолчанию; `OPENBAO__NATS__NATS_RESULT_SUBJECT`): ack `status=processing`, `Nats-Msg-Id = ack:<job_id>:<attempt>`; итог
`status = done | needs_check | error`, `Nats-Msg-Id = result:<job_id>:<attempt>`. `service.name = "scan-gateway"`. `status = done | needs_check | error`, `Nats-Msg-Id = result:<job_id>:<attempt>`. `service.name = "scan-gateway"`.
- Ключи S3: исходник — из запроса (`s3.key`), сырой ответ (склейка файлов ИС) — `raw/{job_id}.json`, результат — - Ключи S3: исходник — из запроса (`s3.key`), сырой ответ (склейка файлов ИС) — `raw/{job_id}.json`, результат —
`results/{file_id}/{job_id}/result.json`. Бакет один. `results/{file_id}/{job_id}/result.json`. Бакет один.
@ -445,6 +448,7 @@ make golden-update # пересобрать эталоны нормализ
| `version` | `VERSION` | | `version` | `VERSION` |
| `nats` | `ok` — соединение установлено; `down` — подключение идёт, переподключение, сбой; `disabled` — `NATS_ENABLED=false` | | `nats` | `ok` — соединение установлено; `down` — подключение идёт, переподключение, сбой; `disabled` — `NATS_ENABLED=false` |
| `hotfolder` | `ok` — `HotFolder.healthy()` (корень существует и доступен); `down` — нет, проверка не уложилась в 2 с или упала | | `hotfolder` | `ok` — `HotFolder.healthy()` (корень существует и доступен); `down` — нет, проверка не уложилась в 2 с или упала |
| `subjects` | `{image_request, result}` — subject'ы NATS из настроек; отдаётся при любом состоянии NATS; JWT/seed в ответе нет |
Подключение к NATS идёт в фоне: HTTP отвечает сразу (`nats = down`), подписка появляется после подключения. Подключение к NATS идёт в фоне: HTTP отвечает сразу (`nats = down`), подписка появляется после подключения.
Результат проверки горячей папки кэшируется на 5 с. Результат проверки горячей папки кэшируется на 5 с.

View File

@ -26,8 +26,14 @@ OPENBAO__MINIO__MINIO_SECURE=false
OPENBAO__NATS__NATS_URL=nats://localhost:4222 OPENBAO__NATS__NATS_URL=nats://localhost:4222
OPENBAO__NATS__NATS_SEED= OPENBAO__NATS__NATS_SEED=
OPENBAO__NATS__NATS_JWT= OPENBAO__NATS__NATS_JWT=
# Subject'ы по окружению (прод и тест в одном NATS — разные наборы). Умолчания — имена контракта; у ядра и сервисов
# одного окружения значения обязаны совпадать. Сервис использует запрос `image` и ответ; три имени попарно различны,
# без пробелов, '*' и '>'. Фактические имена видны в логе старта и в /healthcheck.subjects.
# OPENBAO__NATS__NATS_TABULAR_REQUEST_SUBJECT=files.tabular_uploaded
# OPENBAO__NATS__NATS_IMAGE_REQUEST_SUBJECT=files.img_uploaded
# OPENBAO__NATS__NATS_RESULT_SUBJECT=files.tabular_parsed
# Включает подписку на files.img_uploaded. false — воркер не запускается (сервис поднимется без NATS). # Включает подписку на subject запроса image (по умолчанию files.img_uploaded). false — воркер не запускается (сервис поднимется без NATS).
NATS_ENABLED=true NATS_ENABLED=true
# PUBLISH_RETRIES — число повторов публикации ack/результата в NATS после первой попытки (при 3 — до 4 попыток всего); # PUBLISH_RETRIES — число повторов публикации ack/результата в NATS после первой попытки (при 3 — до 4 попыток всего);
# DONE_CACHE_SIZE — размер LRU обработанных job_id (защита от повторной доставки запроса) # DONE_CACHE_SIZE — размер LRU обработанных job_id (защита от повторной доставки запроса)

View File

@ -1,11 +1,12 @@
"""Точка входа FastAPI сервиса scan-gateway (адаптер ИС Сканирование). Без БД и без фронта. """Точка входа FastAPI сервиса scan-gateway (адаптер ИС Сканирование). Без БД и без фронта.
- `create_app(settings)` строит горячую папку (`create_hotfolder`, без обращения к ФС/шаре) и `HealthProvider`; - `create_app(settings)` строит горячую папку (`create_hotfolder`, без обращения к ФС/шаре) и `HealthProvider`;
`GET /healthcheck` отдаёт `uptime`, `version`, `nats` (`ok`/`down`/`disabled`), `hotfolder` (`ok`/`down`). `GET /healthcheck` отдаёт `uptime`, `version`, `nats` (`ok`/`down`/`disabled`), `hotfolder` (`ok`/`down`),
`subjects` (`image_request`, `result` — subject'ы NATS из настроек).
- lifespan: регистрация SMB-сессии (только `SCAN_HOTFOLDER_MODE=smb`), S3 (`S3Storage`), NATS (`NatsGateway`), воркер - lifespan: регистрация SMB-сессии (только `SCAN_HOTFOLDER_MODE=smb`), S3 (`S3Storage`), NATS (`NatsGateway`), воркер
(`Worker` с `OcrNormalizer`). Справочники нормализатора загружаются из `SCAN_RULES_DIR` в `create_app`: битый (`Worker` с `OcrNormalizer`). Справочники нормализатора загружаются из `SCAN_RULES_DIR` в `create_app`: битый
справочник — `ValueError`, сервис не стартует. Подключение к NATS блокирует до доступности сервера, поэтому идёт справочник — `ValueError`, сервис не стартует. Подключение к NATS блокирует до доступности сервера, поэтому идёт
фоновой задачей: HTTP отвечает сразу (`nats = down`), подписка `files.img_uploaded` появляется после фоновой задачей: HTTP отвечает сразу (`nats = down`), подписка на subject запроса `image` появляется после
подключения. Сбой этой задачи (например, `NatsConfigError`) — лог error с трассировкой и `nats = down`, подключения. Сбой этой задачи (например, `NatsConfigError`) — лог error с трассировкой и `nats = down`,
HTTP-сервер жив. HTTP-сервер жив.
Остановка: отмена подключения, остановка воркера (задания в работе отменяются), закрытие NATS и S3. Остановка: отмена подключения, остановка воркера (задания в работе отменяются), закрытие NATS и S3.
@ -30,7 +31,7 @@ from src.core.config import Settings, get_settings
from src.core.exception_handlers import register_exception_handlers from src.core.exception_handlers import register_exception_handlers
from src.core.health import HealthProvider from src.core.health import HealthProvider
from src.core.logger import setup_logger from src.core.logger import setup_logger
from src.core.nats import NatsGateway from src.core.nats import NatsGateway, used_subjects
from src.core.worker import Worker from src.core.worker import Worker
from src.gateway import create_hotfolder from src.gateway import create_hotfolder
from src.normalizer import OcrNormalizer from src.normalizer import OcrNormalizer
@ -66,10 +67,12 @@ def create_app(settings: Settings | None = None) -> FastAPI:
up_time = datetime.now(UTC) up_time = datetime.now(UTC)
hotfolder = create_hotfolder(settings) hotfolder = create_hotfolder(settings)
normalizer = OcrNormalizer(load_rules(settings.SCAN_RULES_DIR)) # битый справочник — сервис не стартует (D-7) normalizer = OcrNormalizer(load_rules(settings.SCAN_RULES_DIR)) # битый справочник — сервис не стартует (D-7)
health = HealthProvider(nats_enabled=settings.NATS_ENABLED, hotfolder=hotfolder) subjects = used_subjects(settings.OPENBAO.NATS)
health = HealthProvider(nats_enabled=settings.NATS_ENABLED, hotfolder=hotfolder, subjects=subjects)
@asynccontextmanager @asynccontextmanager
async def lifespan(app: FastAPI): async def lifespan(app: FastAPI):
logger.info("Subject'ы NATS. {subjects}", subjects=subjects)
smb_session = False smb_session = False
if settings.SCAN_HOTFOLDER_MODE == "smb": if settings.SCAN_HOTFOLDER_MODE == "smb":
smb = settings.OPENBAO.SMB smb = settings.OPENBAO.SMB

View File

@ -1,4 +1,4 @@
"""Состояние сервиса для `GET /healthcheck`: поля `nats` и `hotfolder` (D-8; spec image-recognition-gateway). """Состояние сервиса для `GET /healthcheck`: поля `nats`, `hotfolder`, `subjects` (D-8; spec image-recognition-gateway).
- `nats`: `disabled` при `NATS_ENABLED=false`; `ok`, если соединение установлено (`is_connected`), иначе `down` — в том - `nats`: `disabled` при `NATS_ENABLED=false`; `ok`, если соединение установлено (`is_connected`), иначе `down` — в том
числе пока идёт первое подключение, во время переподключения и после сбоя фоновой задачи подключения. числе пока идёт первое подключение, во время переподключения и после сбоя фоновой задачи подключения.
@ -11,8 +11,8 @@
import asyncio import asyncio
import time import time
from collections.abc import Callable from collections.abc import Callable, Mapping
from typing import Literal, Protocol from typing import Any, Literal, Protocol
from loguru import logger from loguru import logger
@ -37,11 +37,12 @@ class ConnectionState(Protocol):
class HealthProvider: class HealthProvider:
"""Поля `nats`/`hotfolder` для `/healthcheck`. `nats` присваивается, когда соединение создано (lifespan).""" """Поля `nats`/`hotfolder` для `/healthcheck`. `nats` присваивается, когда соединение создано (lifespan)."""
def __init__( def __init__( # noqa: PLR0913 - именованные параметры: подмены для тестов и тайминги
self, self,
*, *,
nats_enabled: bool, nats_enabled: bool,
hotfolder: HotFolder | None, hotfolder: HotFolder | None,
subjects: Mapping[str, str],
check_timeout: float = HOTFOLDER_CHECK_TIMEOUT, check_timeout: float = HOTFOLDER_CHECK_TIMEOUT,
cache_ttl: float = HOTFOLDER_CACHE_TTL, cache_ttl: float = HOTFOLDER_CACHE_TTL,
clock: Callable[[], float] = time.monotonic, clock: Callable[[], float] = time.monotonic,
@ -49,6 +50,7 @@ class HealthProvider:
self.nats: ConnectionState | None = None self.nats: ConnectionState | None = None
self._nats_enabled = nats_enabled self._nats_enabled = nats_enabled
self._hotfolder = hotfolder self._hotfolder = hotfolder
self._subjects = dict(subjects)
self._check_timeout = check_timeout self._check_timeout = check_timeout
self._cache_ttl = cache_ttl self._cache_ttl = cache_ttl
self._clock = clock self._clock = clock
@ -86,8 +88,13 @@ class HealthProvider:
self._cached, self._cached_at = status, self._clock() self._cached, self._cached_at = status, self._clock()
return status return status
async def snapshot(self) -> dict[str, str]: async def snapshot(self) -> dict[str, Any]:
return {"nats": self.nats_status(), "hotfolder": await self.hotfolder_status()} """`subjects` отдаётся при любом состоянии NATS (имена subject'ов — не секрет)."""
return {
"nats": self.nats_status(),
"hotfolder": await self.hotfolder_status(),
"subjects": dict(self._subjects),
}
def _consume_exception(task: "asyncio.Task[bool]") -> None: def _consume_exception(task: "asyncio.Task[bool]") -> None:

View File

@ -8,7 +8,9 @@
поэтому `connect()` возвращается только при успехе (вызывающий оборачивает его в задачу или `wait_for`). поэтому `connect()` возвращается только при успехе (вызывающий оборачивает его в задачу или `wait_for`).
После переподключения nats-py сам отправляет SUB для всех подписок с прежней queue group — подписку повторно После переподключения nats-py сам отправляет SUB для всех подписок с прежней queue group — подписку повторно
оформлять не нужно. оформлять не нужно.
- `subscribe_requests(cb)` — подписка на `files.img_uploaded` в queue group `scan-gateway`: из нескольких - `subscribe_requests(cb)` — подписка на subject запроса `image` из настроек
(`OPENBAO__NATS__NATS_IMAGE_REQUEST_SUBJECT`,
по умолчанию `files.img_uploaded`) в queue group `scan-gateway`: из нескольких
инстансов сообщение получает ровно один. `cb` получает исходное сообщение nats-py (`msg.data`, `msg.headers`); инстансов сообщение получает ровно один. `cb` получает исходное сообщение nats-py (`msg.data`, `msg.headers`);
исключения `cb` логируются, подписка живёт. исключения `cb` логируются, подписка живёт.
- `publish(subject, payload, msg_id)` — публикация с заголовком `Nats-Msg-Id` и `flush` (подтверждение сервером) с - `publish(subject, payload, msg_id)` — публикация с заголовком `Nats-Msg-Id` и `flush` (подтверждение сервером) с
@ -31,9 +33,7 @@ from nats.aio.client import Client as NATS
from nats.aio.msg import Msg from nats.aio.msg import Msg
from src.contract import ( from src.contract import (
IMAGE_REQUEST_SUBJECT,
NATS_MSG_ID_HEADER, NATS_MSG_ID_HEADER,
RESULT_SUBJECT,
MessageStatus, MessageStatus,
RecognitionResultMessage, RecognitionResultMessage,
ack_msg_id, ack_msg_id,
@ -116,6 +116,11 @@ def build_nats_auth(settings: NATSSettings) -> dict[str, Any]:
return {"user_jwt_cb": user_jwt_cb, "signature_cb": signature_cb} return {"user_jwt_cb": user_jwt_cb, "signature_cb": signature_cb}
def used_subjects(settings: NATSSettings) -> dict[str, str]:
"""Subject'ы сервиса для лога старта и `/healthcheck.subjects`: подписан на запрос `image`, публикует ответ."""
return {"image_request": settings.NATS_IMAGE_REQUEST_SUBJECT, "result": settings.NATS_RESULT_SUBJECT}
def message_msg_id(message: RecognitionResultMessage, attempt: int) -> str: def message_msg_id(message: RecognitionResultMessage, attempt: int) -> str:
"""`Nats-Msg-Id` конверта: `ack:<job_id>:<attempt>` для `processing`, иначе `result:<job_id>:<attempt>`.""" """`Nats-Msg-Id` конверта: `ack:<job_id>:<attempt>` для `processing`, иначе `result:<job_id>:<attempt>`."""
if message.status is MessageStatus.PROCESSING: if message.status is MessageStatus.PROCESSING:
@ -209,15 +214,17 @@ class NatsGateway:
# --- подписка --------------------------------------------------------------------------------------------- # --- подписка ---------------------------------------------------------------------------------------------
async def subscribe_requests(self, cb: MessageHandler, queue: str = REQUEST_QUEUE_GROUP) -> Any: async def subscribe_requests(self, cb: MessageHandler, queue: str = REQUEST_QUEUE_GROUP) -> Any:
"""Подписка на `files.img_uploaded` в queue group `queue`; возвращает подписку nats-py после """Подписка на subject запроса `image` из настроек (по умолчанию `files.img_uploaded`) в queue group
`queue`; возвращает подписку nats-py после
подтверждения сервером (`flush`). подтверждения сервером (`flush`).
`cb` получает исходное `Msg` (`data`, `headers`); его исключения логируются, подписка не падает. `cb` получает исходное `Msg` (`data`, `headers`); его исключения логируются, подписка не падает.
`cb` должен возвращаться быстро: nats-py вызывает колбэки одной подписки последовательно, долгая обработка `cb` должен возвращаться быстро: nats-py вызывает колбэки одной подписки последовательно, долгая обработка
(ожидание ИС) задерживает следующие запросы — её выносят в отдельную задачу. (ожидание ИС) задерживает следующие запросы — её выносят в отдельную задачу.
""" """
subject = self._settings.OPENBAO.NATS.NATS_IMAGE_REQUEST_SUBJECT
if self._client is None: if self._client is None:
raise NatsNotConnectedError(f"Нет соединения с NATS: подписка на {IMAGE_REQUEST_SUBJECT} невозможна") raise NatsNotConnectedError(f"Нет соединения с NATS: подписка на {subject} невозможна")
async def safe_cb(msg: Msg) -> None: async def safe_cb(msg: Msg) -> None:
try: try:
@ -230,11 +237,9 @@ class NatsGateway:
error=repr(exc), error=repr(exc),
) )
subscription = await self._client.subscribe(IMAGE_REQUEST_SUBJECT, queue=queue, cb=safe_cb) subscription = await self._client.subscribe(subject, queue=queue, cb=safe_cb)
await self._client.flush(timeout=self._flush_timeout) # SUB принят сервером: подписка действует по возврату await self._client.flush(timeout=self._flush_timeout) # SUB принят сервером: подписка действует по возврату
logger.info( logger.info("Подписка NATS активна. subject={subject}, queue={queue}", subject=subject, queue=queue)
"Подписка NATS активна. subject={subject}, queue={queue}", subject=IMAGE_REQUEST_SUBJECT, queue=queue
)
return subscription return subscription
# --- публикация ------------------------------------------------------------------------------------------- # --- публикация -------------------------------------------------------------------------------------------
@ -292,10 +297,11 @@ class NatsGateway:
) from exc ) from exc
async def publish_result(self, message: RecognitionResultMessage, attempt: int) -> None: async def publish_result(self, message: RecognitionResultMessage, attempt: int) -> None:
"""Публикует конверт в `files.tabular_parsed`: тело — `message.to_wire()` (JSON, UTF-8), `Nats-Msg-Id` — """Публикует конверт в subject ответа из настроек (по умолчанию `files.tabular_parsed`):
тело — `message.to_wire()` (JSON, UTF-8), `Nats-Msg-Id` —
`ack:…` для `processing`, `result:…` для итога; `attempt` — из запроса. Ошибки — как у `publish`.""" `ack:…` для `processing`, `result:…` для итога; `attempt` — из запроса. Ошибки — как у `publish`."""
payload = json.dumps(message.to_wire(), ensure_ascii=False).encode("utf-8") payload = json.dumps(message.to_wire(), ensure_ascii=False).encode("utf-8")
await self.publish(RESULT_SUBJECT, payload, message_msg_id(message, attempt)) await self.publish(self._settings.OPENBAO.NATS.NATS_RESULT_SUBJECT, payload, message_msg_id(message, attempt))
# --- колбэки nats-py -------------------------------------------------------------------------------------- # --- колбэки nats-py --------------------------------------------------------------------------------------
async def _on_error(self, exc: Exception) -> None: async def _on_error(self, exc: Exception) -> None:

View File

@ -3,7 +3,9 @@
Недоступная инфраструктура — падение с понятным сообщением, не skip. Общий контейнер NATS не перезапускается: Недоступная инфраструктура — падение с понятным сообщением, не skip. Общий контейнер NATS не перезапускается:
сценарий переподключения покрыт unit-тестом колбэков (tests/unit/test_nats.py). Чужой трафик на тех же subject'ах сценарий переподключения покрыт unit-тестом колбэков (tests/unit/test_nats.py). Чужой трафик на тех же subject'ах
отсекается уникальным `job_id` теста; ключи MinIO — под уникальным префиксом `itest/<uuid>/` и удаляются. отсекается уникальным `job_id` теста; кроме того, тесты работают на своих subject'ах прогона (`TEST_SUBJECTS`, суффикс
`RUN`), поэтому контейнеры стенда на умолчательных `files.*` их не видят; ключи MinIO — под уникальным префиксом
`itest/<uuid>/` и удаляются.
""" """
import asyncio import asyncio
@ -18,22 +20,23 @@ import pytest
from nats.aio.client import Client as NATS from nats.aio.client import Client as NATS
from nats.aio.msg import Msg from nats.aio.msg import Msg
from src.contract import ( from src.contract import NATS_MSG_ID_HEADER, ServiceInfo, build_ack_message
IMAGE_REQUEST_SUBJECT,
NATS_MSG_ID_HEADER,
RESULT_SUBJECT,
ServiceInfo,
build_ack_message,
)
from src.core.clients import S3ObjectNotFoundError, S3Storage from src.core.clients import S3ObjectNotFoundError, S3Storage
from src.core.config import Settings, get_settings from src.core.config import Settings, get_settings
from src.core.nats import REQUEST_QUEUE_GROUP, NatsGateway from src.core.nats import REQUEST_QUEUE_GROUP, NatsGateway, used_subjects
pytestmark = pytest.mark.integration pytestmark = pytest.mark.integration
CONNECT_WAIT = 5.0 # секунд на подключение к NATS (nats-py ждёт сервер бесконечно — ограничиваем снаружи) CONNECT_WAIT = 5.0 # секунд на подключение к NATS (nats-py ждёт сервер бесконечно — ограничиваем снаружи)
DELIVERY_WAIT = 5.0 DELIVERY_WAIT = 5.0
# Уникальные subject'ы прогона (OPENBAO__NATS__NATS_*_SUBJECT): общий NATS стенда слушают другие экземпляры сервисов
RUN = uuid.uuid4().hex[:8]
TEST_SUBJECTS = {
"image_request": f"itest.{RUN}.files.img_uploaded",
"result": f"itest.{RUN}.files.tabular_parsed",
}
def _require_port(host: str, port: int, what: str) -> None: def _require_port(host: str, port: int, what: str) -> None:
try: try:
@ -44,8 +47,12 @@ def _require_port(host: str, port: int, what: str) -> None:
@pytest.fixture @pytest.fixture
def settings() -> Settings: def settings(monkeypatch: pytest.MonkeyPatch) -> Settings:
monkeypatch.setenv("OPENBAO__NATS__NATS_IMAGE_REQUEST_SUBJECT", TEST_SUBJECTS["image_request"])
monkeypatch.setenv("OPENBAO__NATS__NATS_RESULT_SUBJECT", TEST_SUBJECTS["result"])
get_settings.cache_clear()
settings = get_settings() settings = get_settings()
assert used_subjects(settings.OPENBAO.NATS) == TEST_SUBJECTS
nats_url = urlparse(settings.OPENBAO.NATS.NATS_URL) nats_url = urlparse(settings.OPENBAO.NATS.NATS_URL)
_require_port(nats_url.hostname or "127.0.0.1", nats_url.port or 4222, "NATS") _require_port(nats_url.hostname or "127.0.0.1", nats_url.port or 4222, "NATS")
host, _, port = settings.OPENBAO.MINIO.MINIO_URL.partition(":") host, _, port = settings.OPENBAO.MINIO.MINIO_URL.partition(":")
@ -102,16 +109,16 @@ async def test_subscribe_and_publish_with_msg_id_without_jetstream(settings: Set
received: list[Msg] = [] received: list[Msg] = []
async def on_request(msg: Msg) -> None: async def on_request(msg: Msg) -> None:
if json.loads(msg.data).get("job_id") == job_id: # чужие сообщения на общем subject пропускаем if json.loads(msg.data).get("job_id") == job_id: # страховка: на своих subject'ах чужого трафика нет
received.append(msg) received.append(msg)
await subscriber.subscribe_requests(on_request) await subscriber.subscribe_requests(on_request)
await publisher.publish(IMAGE_REQUEST_SUBJECT, json.dumps({"job_id": job_id}).encode(), f"{job_id}:1") await publisher.publish(TEST_SUBJECTS["image_request"], json.dumps({"job_id": job_id}).encode(), f"{job_id}:1")
await _wait_until(lambda: len(received) >= 1) await _wait_until(lambda: len(received) >= 1)
assert len(received) == 1 assert len(received) == 1
[msg] = received [msg] = received
assert msg.subject == IMAGE_REQUEST_SUBJECT assert msg.subject == TEST_SUBJECTS["image_request"]
assert msg.headers is not None assert msg.headers is not None
assert msg.headers[NATS_MSG_ID_HEADER] == f"{job_id}:1" assert msg.headers[NATS_MSG_ID_HEADER] == f"{job_id}:1"
@ -138,7 +145,7 @@ async def test_queue_group_delivers_each_message_to_exactly_one_member(settings:
total = 40 total = 40
sent = {f"{run_id}-{n}:1" for n in range(total)} sent = {f"{run_id}-{n}:1" for n in range(total)}
for msg_id in sorted(sent): for msg_id in sorted(sent):
await publisher.publish(IMAGE_REQUEST_SUBJECT, json.dumps({"run_id": run_id}).encode(), msg_id) await publisher.publish(TEST_SUBJECTS["image_request"], json.dumps({"run_id": run_id}).encode(), msg_id)
await _wait_until(lambda: len(deliveries) >= total) await _wait_until(lambda: len(deliveries) >= total)
await asyncio.sleep(0.3) # лишние (дублирующие) доставки успели бы прийти await asyncio.sleep(0.3) # лишние (дублирующие) доставки успели бы прийти
@ -161,7 +168,7 @@ async def test_publish_result_reaches_result_subject_with_ack_msg_id(settings: S
received.append(msg) received.append(msg)
try: try:
await listener.subscribe(RESULT_SUBJECT, cb=on_result) await listener.subscribe(TEST_SUBJECTS["result"], cb=on_result)
await listener.flush() await listener.flush()
message = build_ack_message(job_id, file_id, ServiceInfo(name="scan-gateway", version="1.0.0")) message = build_ack_message(job_id, file_id, ServiceInfo(name="scan-gateway", version="1.0.0"))
await gateway.publish_result(message, attempt=1) await gateway.publish_result(message, attempt=1)

View File

@ -22,14 +22,12 @@
ответа, в порядке возрастания имени, с телами байт в байт; входящий файл совпадает с объектом S3. Готовность набора ответа, в порядке возрастания имени, с телами байт в байт; входящий файл совпадает с объектом S3. Готовность набора
— по settle (`SETTLE_SECONDS`, 0.2 с). — по settle (`SETTLE_SECONDS`, 0.2 с).
Общая инфраструктура: NATS и MinIO используют и другие сервисы workspace. Чужие конверты на `files.tabular_parsed` Общая инфраструктура: NATS и MinIO используют и другие сервисы workspace. Тест слушает ответы на своём
отсекаются по `job_id` теста; ключи MinIO уникальны (`uploads/itest-<uuid>/…`, `raw/<job_id>.json`, `results/…`) и subject'е прогона; конверты отсекаются по `job_id` теста; ключи MinIO уникальны (`uploads/itest-<uuid>/…`,
удаляются. `raw/<job_id>.json`, `results/…`) и удаляются.
Подписка шлюза идёт в queue group `scan-gateway`: если в этом NATS живёт другой экземпляр scan-gateway, он может Подписка шлюза идёт в queue group `scan-gateway`. Тест работает на своих subject'ах прогона (`TEST_SUBJECTS`), поэтому
забрать запрос теста (тест тогда упадёт по таймауту ожидания ack). Перед запуском занятость группы можно проверить экземпляры стенда на умолчательных `files.*` его запросы не получают. Недоступная инфраструктура — падение теста с
мониторингом NATS: `curl -s 'http://127.0.0.1:8222/subsz?subs=1'` — подписчиков `files.img_uploaded` быть понятным сообщением, не skip; нет репозитория fixtures — skip сценариев на реальных данных (маркер `fixtures`).
не должно. Автоматической защиты в тесте нет намеренно (она была бы гонкой). Недоступная инфраструктура — падение
теста с понятным сообщением, не skip; нет репозитория fixtures — skip сценариев на реальных данных (маркер `fixtures`).
""" """
import asyncio import asyncio
@ -56,9 +54,10 @@ from nats.aio.client import Client as NATS
from nats.aio.msg import Msg from nats.aio.msg import Msg
from src.app import create_app from src.app import create_app
from src.contract import CONTRACT_VERSION, IMAGE_REQUEST_SUBJECT, NATS_MSG_ID_HEADER, RESULT_SUBJECT, request_msg_id from src.contract import CONTRACT_VERSION, NATS_MSG_ID_HEADER, request_msg_id
from src.core.clients import JSON_CONTENT_TYPE, S3Storage from src.core.clients import JSON_CONTENT_TYPE, S3Storage
from src.core.config import Settings, get_settings from src.core.config import Settings, get_settings
from src.core.nats import used_subjects
from src.scan_emulator import EmulatorSettings, run from src.scan_emulator import EmulatorSettings, run
pytestmark = [ pytestmark = [
@ -76,6 +75,13 @@ CONTRACTS_DIR = API_DIR / "contracts"
REAL_FIXTURES = API_DIR.parent.parent / "fixtures" / "ocr" REAL_FIXTURES = API_DIR.parent.parent / "fixtures" / "ocr"
GOLDEN_JOB_ID = "3f1c2b7a-0000-4000-8000-000000000001" # условный job_id эталонов (`tests/golden_update.py`) GOLDEN_JOB_ID = "3f1c2b7a-0000-4000-8000-000000000001" # условный job_id эталонов (`tests/golden_update.py`)
# Уникальные subject'ы прогона (OPENBAO__NATS__NATS_*_SUBJECT): шлюз теста и слушатель работают на них, контейнеры
# стенда на умолчательных `files.*` запросы теста не получают и не отвечают на них
RUN = uuid.uuid4().hex[:8]
TEST_SUBJECTS = {
"image_request": f"itest.{RUN}.files.img_uploaded",
"result": f"itest.{RUN}.files.tabular_parsed",
}
CONNECT_WAIT = 10.0 # секунд на подключение к NATS (тест и фоновая задача приложения) CONNECT_WAIT = 10.0 # секунд на подключение к NATS (тест и фоновая задача приложения)
SCENARIO_WAIT = 20.0 # секунд на ожидание конвертов одного сценария SCENARIO_WAIT = 20.0 # секунд на ожидание конвертов одного сценария
QUIET_PERIOD = 1.0 # секунд тишины после повторного запроса: второго результата быть не должно QUIET_PERIOD = 1.0 # секунд тишины после повторного запроса: второго результата быть не должно
@ -208,7 +214,7 @@ class Roundtrip:
"""Публикация как в ядре: `files.img_uploaded`, `Nats-Msg-Id = <job_id>:<attempt>`.""" """Публикация как в ядре: `files.img_uploaded`, `Nats-Msg-Id = <job_id>:<attempt>`."""
payload = json.dumps(body, ensure_ascii=False).encode("utf-8") payload = json.dumps(body, ensure_ascii=False).encode("utf-8")
headers = {NATS_MSG_ID_HEADER: request_msg_id(body["job_id"], body["attempt"])} headers = {NATS_MSG_ID_HEADER: request_msg_id(body["job_id"], body["attempt"])}
await self.publisher.publish(IMAGE_REQUEST_SUBJECT, payload, headers=headers) await self.publisher.publish(TEST_SUBJECTS["image_request"], payload, headers=headers)
await self.publisher.flush() await self.publisher.flush()
# --- конверты ------------------------------------------------------------------------------------------------- # --- конверты -------------------------------------------------------------------------------------------------
@ -357,7 +363,11 @@ def _settings(monkeypatch: pytest.MonkeyPatch, root: Path, *, cleanup: bool) ->
monkeypatch.setenv("SCAN_TIMEOUT_SECONDS", str(TIMEOUT_SECONDS)) monkeypatch.setenv("SCAN_TIMEOUT_SECONDS", str(TIMEOUT_SECONDS))
monkeypatch.setenv("SCAN_CLEANUP", "true" if cleanup else "false") monkeypatch.setenv("SCAN_CLEANUP", "true" if cleanup else "false")
monkeypatch.setenv("NATS_ENABLED", "true") monkeypatch.setenv("NATS_ENABLED", "true")
monkeypatch.setenv("OPENBAO__NATS__NATS_IMAGE_REQUEST_SUBJECT", TEST_SUBJECTS["image_request"])
monkeypatch.setenv("OPENBAO__NATS__NATS_RESULT_SUBJECT", TEST_SUBJECTS["result"])
get_settings.cache_clear()
settings = get_settings() settings = get_settings()
assert used_subjects(settings.OPENBAO.NATS) == TEST_SUBJECTS
parsed = urlparse(settings.OPENBAO.NATS.NATS_URL) parsed = urlparse(settings.OPENBAO.NATS.NATS_URL)
_require_port(parsed.hostname or "127.0.0.1", parsed.port or 4222, "NATS") _require_port(parsed.hostname or "127.0.0.1", parsed.port or 4222, "NATS")
host, _, port = settings.OPENBAO.MINIO.MINIO_URL.partition(":") host, _, port = settings.OPENBAO.MINIO.MINIO_URL.partition(":")
@ -366,7 +376,7 @@ def _settings(monkeypatch: pytest.MonkeyPatch, root: Path, *, cleanup: bool) ->
async def _start_gateway(stack: AsyncExitStack, app: FastAPI) -> None: async def _start_gateway(stack: AsyncExitStack, app: FastAPI) -> None:
"""Lifespan приложения; возврат — когда шлюз подписан на `files.img_uploaded`.""" """Lifespan приложения; возврат — когда шлюз подписан на subject запроса."""
await stack.enter_async_context(app.router.lifespan_context(app)) await stack.enter_async_context(app.router.lifespan_context(app))
[connect_task] = [t for t in asyncio.all_tasks() if t.get_name() == "nats-connect"] [connect_task] = [t for t in asyncio.all_tasks() if t.get_name() == "nats-connect"]
# задача завершается после SUB с flush: дальше подписка шлюза действует на сервере # задача завершается после SUB с flush: дальше подписка шлюза действует на сервере
@ -432,7 +442,7 @@ async def _roundtrip(
rt.received.append(msg) rt.received.append(msg)
# core NATS без JetStream не хранит сообщения: слушатель подписан до первой публикации запроса # core NATS без JetStream не хранит сообщения: слушатель подписан до первой публикации запроса
await client.subscribe(RESULT_SUBJECT, cb=on_result) await client.subscribe(TEST_SUBJECTS["result"], cb=on_result)
await client.flush() await client.flush()
await _start_gateway(stack, rt.app) await _start_gateway(stack, rt.app)
_start_emulator(stack, settings, root, tmp_path / "fixtures") _start_emulator(stack, settings, root, tmp_path / "fixtures")

View File

@ -52,11 +52,29 @@ async def test_healthcheck_without_lifespan_has_all_fields(local_env: Path) -> N
assert response.status_code == 200 assert response.status_code == 200
body = response.json() body = response.json()
assert set(body) == {"uptime", "version", "nats", "hotfolder"} assert set(body) == {"uptime", "version", "nats", "hotfolder", "subjects"}
assert body["version"] == get_settings().VERSION assert body["version"] == get_settings().VERSION
assert body["subjects"] == {"image_request": "files.img_uploaded", "result": "files.tabular_parsed"}
datetime.fromisoformat(body["uptime"]) datetime.fromisoformat(body["uptime"])
async def test_healthcheck_subjects_from_env_without_jwt(local_env: Path, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("OPENBAO__NATS__NATS_IMAGE_REQUEST_SUBJECT", "test.files.img_uploaded")
monkeypatch.setenv("OPENBAO__NATS__NATS_RESULT_SUBJECT", "test.files.tabular_parsed")
monkeypatch.setenv("OPENBAO__NATS__NATS_JWT", "secret-jwt-value")
get_settings.cache_clear()
app = create_app(get_settings())
async with _client(app) as client:
response = await client.get("/healthcheck")
assert response.json()["subjects"] == {
"image_request": "test.files.img_uploaded",
"result": "test.files.tabular_parsed",
}
assert "secret-jwt-value" not in response.text
async def test_start_in_local_mode(local_env: Path) -> None: async def test_start_in_local_mode(local_env: Path) -> None:
"""Spec «Старт в режиме локальных папок»: 200 с uptime, version, nats, hotfolder = ok; OPENBAO__SMB__* не нужны.""" """Spec «Старт в режиме локальных папок»: 200 с uptime, version, nats, hotfolder = ok; OPENBAO__SMB__* не нужны."""
app = create_app(get_settings()) app = create_app(get_settings())

View File

@ -11,6 +11,8 @@ from src.gateway.hotfolder import AnswerFile, LocalHotFolder
pytestmark = pytest.mark.usefixtures("log_lines") pytestmark = pytest.mark.usefixtures("log_lines")
SUBJECTS = {"image_request": "files.img_uploaded", "result": "files.tabular_parsed"}
class Connection: class Connection:
def __init__(self, connected: bool) -> None: def __init__(self, connected: bool) -> None:
@ -50,17 +52,17 @@ class Clock:
async def test_local_root_exists_is_ok(tmp_path: Path) -> None: async def test_local_root_exists_is_ok(tmp_path: Path) -> None:
health = HealthProvider(nats_enabled=False, hotfolder=LocalHotFolder(tmp_path)) health = HealthProvider(nats_enabled=False, hotfolder=LocalHotFolder(tmp_path), subjects=SUBJECTS)
assert await health.snapshot() == {"nats": "disabled", "hotfolder": "ok"} assert await health.snapshot() == {"nats": "disabled", "hotfolder": "ok", "subjects": SUBJECTS}
async def test_missing_root_is_down(tmp_path: Path) -> None: async def test_missing_root_is_down(tmp_path: Path) -> None:
health = HealthProvider(nats_enabled=False, hotfolder=LocalHotFolder(tmp_path / "missing")) health = HealthProvider(nats_enabled=False, hotfolder=LocalHotFolder(tmp_path / "missing"), subjects=SUBJECTS)
assert await health.hotfolder_status() == "down" assert await health.hotfolder_status() == "down"
async def test_no_hotfolder_is_down() -> None: async def test_no_hotfolder_is_down() -> None:
assert await HealthProvider(nats_enabled=False, hotfolder=None).hotfolder_status() == "down" assert await HealthProvider(nats_enabled=False, hotfolder=None, subjects=SUBJECTS).hotfolder_status() == "down"
@pytest.mark.parametrize( @pytest.mark.parametrize(
@ -74,7 +76,7 @@ async def test_no_hotfolder_is_down() -> None:
], ],
) )
def test_nats_status(enabled: bool, connection: Connection | None, expected: str) -> None: def test_nats_status(enabled: bool, connection: Connection | None, expected: str) -> None:
health = HealthProvider(nats_enabled=enabled, hotfolder=None) health = HealthProvider(nats_enabled=enabled, hotfolder=None, subjects=SUBJECTS)
health.nats = connection health.nats = connection
assert health.nats_status() == expected assert health.nats_status() == expected
@ -82,7 +84,7 @@ def test_nats_status(enabled: bool, connection: Connection | None, expected: str
async def test_hotfolder_result_is_cached_for_ttl() -> None: async def test_hotfolder_result_is_cached_for_ttl() -> None:
hotfolder = CountingHotFolder(result=True) hotfolder = CountingHotFolder(result=True)
clock = Clock() clock = Clock()
health = HealthProvider(nats_enabled=False, hotfolder=hotfolder, cache_ttl=5, clock=clock) health = HealthProvider(nats_enabled=False, hotfolder=hotfolder, cache_ttl=5, clock=clock, subjects=SUBJECTS)
assert await health.hotfolder_status() == "ok" assert await health.hotfolder_status() == "ok"
hotfolder.result = False hotfolder.result = False
clock.now += 4.9 clock.now += 4.9
@ -97,7 +99,9 @@ async def test_hanging_check_is_down_within_timeout_and_not_repeated() -> None:
hotfolder = CountingHotFolder() hotfolder = CountingHotFolder()
hotfolder.release = threading.Event() hotfolder.release = threading.Event()
clock = Clock() clock = Clock()
health = HealthProvider(nats_enabled=False, hotfolder=hotfolder, check_timeout=0.05, cache_ttl=5, clock=clock) health = HealthProvider(
nats_enabled=False, hotfolder=hotfolder, check_timeout=0.05, cache_ttl=5, clock=clock, subjects=SUBJECTS
)
try: try:
assert await health.hotfolder_status() == "down" assert await health.hotfolder_status() == "down"
clock.now += 10 # кэш истёк, но прежняя проверка ещё висит — новый поток не запускается clock.now += 10 # кэш истёк, но прежняя проверка ещё висит — новый поток не запускается
@ -110,11 +114,25 @@ async def test_hanging_check_is_down_within_timeout_and_not_repeated() -> None:
async def test_check_raising_is_down() -> None: async def test_check_raising_is_down() -> None:
hotfolder = CountingHotFolder() hotfolder = CountingHotFolder()
hotfolder.error = OSError("smb") hotfolder.error = OSError("smb")
health = HealthProvider(nats_enabled=False, hotfolder=hotfolder) health = HealthProvider(nats_enabled=False, hotfolder=hotfolder, subjects=SUBJECTS)
assert await health.hotfolder_status() == "down" assert await health.hotfolder_status() == "down"
async def test_snapshot_with_connected_nats(tmp_path: Path) -> None: async def test_snapshot_with_connected_nats(tmp_path: Path) -> None:
health = HealthProvider(nats_enabled=True, hotfolder=LocalHotFolder(tmp_path)) health = HealthProvider(nats_enabled=True, hotfolder=LocalHotFolder(tmp_path), subjects=SUBJECTS)
health.nats = Connection(True) health.nats = Connection(True)
assert await health.snapshot() == {"nats": "ok", "hotfolder": "ok"} assert await health.snapshot() == {"nats": "ok", "hotfolder": "ok", "subjects": SUBJECTS}
@pytest.mark.parametrize(
("enabled", "connection", "expected"),
[(True, Connection(True), "ok"), (True, Connection(False), "down"), (False, None, "disabled")],
)
async def test_snapshot_contains_subjects(enabled: bool, connection: Connection | None, expected: str) -> None:
health = HealthProvider(nats_enabled=enabled, hotfolder=None, subjects=SUBJECTS)
health.nats = connection
snapshot = await health.snapshot()
assert snapshot["nats"] == expected
assert snapshot["subjects"] == SUBJECTS

View File

@ -21,13 +21,14 @@ from src.contract import (
build_ack_message, build_ack_message,
build_error_message, build_error_message,
) )
from src.core.config import Settings, get_settings from src.core.config import NATSSettings, Settings, get_settings
from src.core.nats import ( from src.core.nats import (
REQUEST_QUEUE_GROUP, REQUEST_QUEUE_GROUP,
NatsConfigError, NatsConfigError,
NatsGateway, NatsGateway,
NatsNotConnectedError, NatsNotConnectedError,
PublishFailed, PublishFailed,
used_subjects,
) )
@ -429,3 +430,51 @@ async def test_publish_result_final_uses_result_msg_id_and_utf8(gateway: NatsGat
assert headers == {"Nats-Msg-Id": f"result:{job_id}:1"} assert headers == {"Nats-Msg-Id": f"result:{job_id}:1"}
assert "ИС не ответила".encode() in payload # UTF-8 без \u-экранирования assert "ИС не ответила".encode() in payload # UTF-8 без \u-экранирования
assert json.loads(payload) == message.to_wire() assert json.loads(payload) == message.to_wire()
# --- subject'ы из настроек (#32) ---------------------------------------------------------------------------------
CUSTOM_ENV = {
"OPENBAO__NATS__NATS_IMAGE_REQUEST_SUBJECT": "test.files.img_uploaded",
"OPENBAO__NATS__NATS_RESULT_SUBJECT": "test.files.tabular_parsed",
}
def test_used_subjects_scan_gateway() -> None:
nats = NATSSettings(NATS_URL="nats://x:4222", NATS_IMAGE_REQUEST_SUBJECT="test.files.img_uploaded")
assert used_subjects(nats) == {"image_request": "test.files.img_uploaded", "result": "files.tabular_parsed"}
async def test_subscribe_requests_uses_subject_from_settings(monkeypatch: pytest.MonkeyPatch, fake: FakeNatsClient):
gw = NatsGateway(make_settings(monkeypatch, **CUSTOM_ENV), client_factory=lambda: fake)
async def on_request(msg: Any) -> None:
return None
await gw.connect()
await gw.subscribe_requests(on_request)
[subscription] = fake.subscriptions
assert subscription["subject"] == "test.files.img_uploaded"
assert subscription["queue"] == "scan-gateway"
async def test_subscribe_requests_error_names_configured_subject(monkeypatch: pytest.MonkeyPatch):
gw = NatsGateway(make_settings(monkeypatch, **CUSTOM_ENV))
async def on_request(msg: Any) -> None:
return None
with pytest.raises(NatsNotConnectedError, match=r"test\.files\.img_uploaded"):
await gw.subscribe_requests(on_request)
async def test_publish_result_uses_subject_from_settings(monkeypatch: pytest.MonkeyPatch, fake: FakeNatsClient):
gw = NatsGateway(make_settings(monkeypatch, **CUSTOM_ENV), client_factory=lambda: fake)
message = build_ack_message(uuid.uuid4(), uuid.uuid4(), SERVICE)
await gw.connect()
await gw.publish_result(message, attempt=1)
[(subject, _, _)] = fake.published
assert subject == "test.files.tabular_parsed"