NATS: подписка, публикация и /healthcheck.subjects по настройкам; интеграционные тесты на своих subject'ах (#32)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
f1f7d784a5
commit
192500e9f9
10
README.md
10
README.md
@ -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 с.
|
||||||
|
|||||||
@ -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 (защита от повторной доставки запроса)
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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:
|
||||||
|
|||||||
@ -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:
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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")
|
||||||
|
|||||||
@ -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())
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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"
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user