Andrey Epifancev 6c287ef4bc nats: subject'ы как в старом АРМ — files.*_uploaded, files.tabular_parsed (#22)
Решение владельца 08.10.2026: recognition.tabular.request → files.tabular_uploaded,
recognition.image.request → files.img_uploaded, recognition.result →
files.tabular_parsed (общий subject ack и результатов; files.img_parsed не
используется). Тела сообщений, Nats-Msg-Id, queue group'ы и schema_version не
меняются. Константы api/src/contract/subjects.py, докстринги, тесты, README,
CLAUDE.md; копии схем api/contracts/ из docs/contracts/ побайтно; change
nats-files-subjects с дельтой спеки. Сквозной change — RSHB/arm-ka-docs#74.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2
2026-10-08 23:46:19 +04:00

318 lines
17 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Соединение scan-gateway с NATS: core NATS без JetStream (design D-2, D-7, D-8; spec image-recognition-gateway,
«Соединение с NATS без JetStream»; docs/context/03-architecture.md, «Контракт обмена через NATS»).
- Аутентификация как в шаблоне: заданы `NATS_SEED` и `NATS_JWT` — nkeys/JWT; оба пусты — без учётных данных; задан
только один или seed не читается — `NatsConfigError` (подключиться «как-нибудь» без учётных данных нельзя).
- `NATS_ENABLED=false` — `connect()` ничего не делает и возвращает `False` (dev без NATS).
- Переподключение — встроенное в nats-py (`max_reconnect_attempts=-1`): клиент сам повторяет и первое подключение,
поэтому `connect()` возвращается только при успехе (вызывающий оборачивает его в задачу или `wait_for`).
После переподключения nats-py сам отправляет SUB для всех подписок с прежней queue group — подписку повторно
оформлять не нужно.
- `subscribe_requests(cb)` — подписка на `files.img_uploaded` в queue group `scan-gateway`: из нескольких
инстансов сообщение получает ровно один. `cb` получает исходное сообщение nats-py (`msg.data`, `msg.headers`);
исключения `cb` логируются, подписка живёт.
- `publish(subject, payload, msg_id)` — публикация с заголовком `Nats-Msg-Id` и `flush` (подтверждение сервером) с
повторами на временных ошибках (первая попытка + до `PUBLISH_RETRIES` повторов); исчерпаны — `PublishFailed`.
Воркер логирует error и идёт дальше: ядро закроет задание по `reply_deadline` (D-7).
- Stream'ов и consumer'ов JetStream нет: `jetstream()` не вызывается.
"""
import asyncio
import base64
import json
from collections.abc import Awaitable, Callable
from contextlib import suppress
from typing import Any
import nkeys
from loguru import logger
from nats import errors as nats_errors
from nats.aio.client import Client as NATS
from nats.aio.msg import Msg
from src.contract import (
IMAGE_REQUEST_SUBJECT,
NATS_MSG_ID_HEADER,
RESULT_SUBJECT,
MessageStatus,
RecognitionResultMessage,
ack_msg_id,
result_msg_id,
)
from src.core.config import NATSSettings, Settings
REQUEST_QUEUE_GROUP = "scan-gateway"
MAX_RECONNECT_ATTEMPTS = -1 # переподключаться бесконечно
RECONNECT_TIME_WAIT = 2 # секунд между попытками переподключения nats-py
CONNECT_TIMEOUT = 2 # секунд на одну попытку TCP-подключения
FLUSH_TIMEOUT = 5 # секунд на подтверждение публикации сервером (PING/PONG)
RETRY_DELAY = 0.5 # секунд между попытками публикации
MessageHandler = Callable[[Msg], Awaitable[None]]
SleepFn = Callable[[float], Awaitable[None]]
# Временные ошибки публикации: соединение закрыто/переподключается/переполнен буфер на время переподключения,
# сервер не подтвердил flush. Повтор имеет смысл. Остальное (MaxPayloadError, BadSubjectError, ошибки программы)
# повтором не лечится — `PublishFailed` сразу.
TRANSIENT_PUBLISH_ERRORS: tuple[type[BaseException], ...] = (
nats_errors.ConnectionClosedError,
nats_errors.ConnectionReconnectingError,
nats_errors.ConnectionDrainingError,
nats_errors.OutboundBufferLimitError,
nats_errors.StaleConnectionError,
nats_errors.NoServersError,
TimeoutError, # nats.errors.TimeoutError / FlushTimeoutError — его подклассы
ConnectionError,
)
class NatsError(Exception):
"""Базовая ошибка слоя NATS сервиса."""
class NatsConfigError(NatsError, RuntimeError):
"""Учётные данные NATS заданы некорректно: подключение не выполняется."""
class NatsNotConnectedError(NatsError):
"""Операция требует соединения с NATS."""
class PublishFailed(NatsError):
"""Публикация не подтверждена: повторы исчерпаны или ошибка не временная. Причина последней попытки — в
`__cause__`; `attempts` — сколько попыток фактически сделано (0 — соединения не было, при исчерпанных повторах —
`retries + 1`)."""
def __init__(self, message: str, *, subject: str, msg_id: str, attempts: int) -> None:
super().__init__(message)
self.subject = subject
self.msg_id = msg_id
self.attempts = attempts
def build_nats_auth(settings: NATSSettings) -> dict[str, Any]:
"""Параметры аутентификации для `Client.connect`: `{}` без учётных данных или колбэки nkeys/JWT шаблона."""
seed = settings.NATS_SEED.strip()
jwt = settings.NATS_JWT.strip()
if not seed and not jwt:
return {}
if not seed or not jwt:
raise NatsConfigError(
"Неполные учётные данные NATS: NATS_SEED и NATS_JWT задаются вместе или не задаются вовсе"
)
try:
keypair = nkeys.from_seed(seed.encode("utf-8"))
except Exception as exc:
raise NatsConfigError(f"Некорректный NATS_SEED: {type(exc).__name__}") from None
def user_jwt_cb() -> bytes:
return jwt.encode("utf-8")
def signature_cb(nonce: str | bytes) -> bytes:
raw = nonce.encode("utf-8") if isinstance(nonce, str) else nonce
signature: bytes = keypair.sign(raw)
return base64.b64encode(signature)
return {"user_jwt_cb": user_jwt_cb, "signature_cb": signature_cb}
def message_msg_id(message: RecognitionResultMessage, attempt: int) -> str:
"""`Nats-Msg-Id` конверта: `ack:<job_id>:<attempt>` для `processing`, иначе `result:<job_id>:<attempt>`."""
if message.status is MessageStatus.PROCESSING:
return ack_msg_id(message.job_id, attempt)
return result_msg_id(message.job_id, attempt)
class NatsGateway:
"""Соединение сервиса с NATS: подключение, подписка на запросы, публикация с `Nats-Msg-Id` и повторами.
`client_factory`, `sleep`, `retry_delay`, `flush_timeout` — подмены для тестов и тайминги.
"""
def __init__( # noqa: PLR0913 - все параметры, кроме settings, именованные: подмены для тестов и тайминги
self,
settings: Settings,
*,
client_factory: Callable[[], Any] = NATS,
sleep: SleepFn = asyncio.sleep,
retry_delay: float = RETRY_DELAY,
flush_timeout: int = FLUSH_TIMEOUT,
) -> None:
self._settings = settings
self._client_factory = client_factory
self._sleep = sleep
self._retry_delay = retry_delay
self._flush_timeout = flush_timeout
self._client: Any = None
self._closing = False
# --- состояние и жизненный цикл ---------------------------------------------------------------------------
@property
def is_connected(self) -> bool:
"""Фактическое состояние: `False` до подключения, при `NATS_ENABLED=false`, во время переподключения и после
`close()`. Источник поля `nats` в `/healthcheck`."""
return self._client is not None and bool(self._client.is_connected)
async def connect(self) -> bool:
"""Подключается к `NATS_URL`; `False` без подключения, если `NATS_ENABLED=false`.
Ждёт сервер сколь угодно долго (nats-py повторяет и первое подключение). Ошибка учётных данных —
`NatsConfigError` до попытки подключения.
"""
if not self._settings.NATS_ENABLED:
logger.info("NATS выключен (NATS_ENABLED=false): подключение не выполняется")
return False
nats_settings = self._settings.OPENBAO.NATS
auth = build_nats_auth(nats_settings)
client = self._client_factory()
self._closing = False
try:
await client.connect(
nats_settings.NATS_URL,
name=self._settings.SERVICE_NAME,
error_cb=self._on_error,
disconnected_cb=self._on_disconnected,
reconnected_cb=self._on_reconnected,
closed_cb=self._on_closed,
max_reconnect_attempts=MAX_RECONNECT_ATTEMPTS,
reconnect_time_wait=RECONNECT_TIME_WAIT,
connect_timeout=CONNECT_TIMEOUT,
**auth,
)
except BaseException:
with suppress(Exception):
await client.close()
raise
self._client = client
logger.info("Подключение к NATS установлено. url={url}", url=nats_settings.NATS_URL)
return True
async def close(self) -> None:
"""Подключённый клиент — `drain` (дообработать полученное и отправить буфер), иначе `close`; без исключений."""
client = self._client
if client is None or client.is_closed:
return
self._closing = True
try:
if client.is_connected:
await client.drain()
else:
await client.close()
except Exception as exc:
logger.warning(
"Ошибка при закрытии соединения NATS. error_type={error_type}, error={error}",
error_type=type(exc).__name__,
error=repr(exc),
)
with suppress(Exception):
await client.close()
# --- подписка ---------------------------------------------------------------------------------------------
async def subscribe_requests(self, cb: MessageHandler, queue: str = REQUEST_QUEUE_GROUP) -> Any:
"""Подписка на `files.img_uploaded` в queue group `queue`; возвращает подписку nats-py после
подтверждения сервером (`flush`).
`cb` получает исходное `Msg` (`data`, `headers`); его исключения логируются, подписка не падает.
`cb` должен возвращаться быстро: nats-py вызывает колбэки одной подписки последовательно, долгая обработка
(ожидание ИС) задерживает следующие запросы — её выносят в отдельную задачу.
"""
if self._client is None:
raise NatsNotConnectedError(f"Нет соединения с NATS: подписка на {IMAGE_REQUEST_SUBJECT} невозможна")
async def safe_cb(msg: Msg) -> None:
try:
await cb(msg)
except Exception as exc:
logger.exception(
"Ошибка обработки сообщения NATS. subject={subject}, error_type={error_type}, error={error}",
subject=msg.subject,
error_type=type(exc).__name__,
error=repr(exc),
)
subscription = await self._client.subscribe(IMAGE_REQUEST_SUBJECT, queue=queue, cb=safe_cb)
await self._client.flush(timeout=self._flush_timeout) # SUB принят сервером: подписка действует по возврату
logger.info(
"Подписка NATS активна. subject={subject}, queue={queue}", subject=IMAGE_REQUEST_SUBJECT, queue=queue
)
return subscription
# --- публикация -------------------------------------------------------------------------------------------
async def publish(self, subject: str, payload: bytes, msg_id: str, *, retries: int | None = None) -> None:
"""Публикует `payload` с заголовком `Nats-Msg-Id: <msg_id>` и ждёт подтверждения сервера (`flush`).
`retries` — число **повторов** после первой попытки (спека: «повторить до трёх раз»; по умолчанию
`PUBLISH_RETRIES` = 3, т.е. не более 4 попыток; `retries = 0` — ровно одна попытка), перед каждым повтором —
пауза `retry_delay`. Повторяются только временные ошибки (`TRANSIENT_PUBLISH_ERRORS`). Повторы исчерпаны,
ошибка не временная или соединения не было вовсе (`attempts = 0`) — `PublishFailed`.
Дубликаты при повторе безопасны: ядро дедуплицирует по `Nats-Msg-Id`.
`PublishFailed` не означает «не доставлено»: сообщения, буферизованные nats-py во время переподключения, могут
уйти после восстановления соединения; ядро отбросит копии по `Nats-Msg-Id`.
"""
retry_count = self._settings.PUBLISH_RETRIES if retries is None else retries
if retry_count < 0:
raise ValueError(f"retries должно быть ≥ 0, получено {retry_count}")
attempts_total = retry_count + 1
if self._client is None:
raise PublishFailed(
f"Нет соединения с NATS: {msg_id} в {subject} не опубликовано",
subject=subject,
msg_id=msg_id,
attempts=0,
)
headers = {NATS_MSG_ID_HEADER: msg_id}
for attempt in range(1, attempts_total + 1):
try:
await self._client.publish(subject, payload, headers=headers)
await self._client.flush(timeout=self._flush_timeout)
return
except TRANSIENT_PUBLISH_ERRORS as exc:
if attempt == attempts_total:
raise PublishFailed(
f"Публикация {msg_id} в {subject} не удалась, попыток: {attempt} ({type(exc).__name__})",
subject=subject,
msg_id=msg_id,
attempts=attempt,
) from exc
logger.warning(
"Публикация в NATS не удалась, повтор. subject={subject}, msg_id={msg_id}, attempt={attempt}, "
"error_type={error_type}",
subject=subject,
msg_id=msg_id,
attempt=attempt,
error_type=type(exc).__name__,
)
await self._sleep(self._retry_delay)
except Exception as exc:
raise PublishFailed(
f"Публикация {msg_id} в {subject} не удалась, ошибка не временная ({type(exc).__name__})",
subject=subject,
msg_id=msg_id,
attempts=attempt,
) from exc
async def publish_result(self, message: RecognitionResultMessage, attempt: int) -> None:
"""Публикует конверт в `files.tabular_parsed`: тело — `message.to_wire()` (JSON, UTF-8), `Nats-Msg-Id` —
`ack:…` для `processing`, `result:…` для итога; `attempt` — из запроса. Ошибки — как у `publish`."""
payload = json.dumps(message.to_wire(), ensure_ascii=False).encode("utf-8")
await self.publish(RESULT_SUBJECT, payload, message_msg_id(message, attempt))
# --- колбэки nats-py --------------------------------------------------------------------------------------
async def _on_error(self, exc: Exception) -> None:
logger.warning(
"Ошибка соединения NATS. error_type={error_type}, error={error}",
error_type=type(exc).__name__,
error=repr(exc),
)
async def _on_disconnected(self) -> None:
if self._closing:
return
logger.warning("Соединение с NATS потеряно, nats-py переподключается")
async def _on_reconnected(self) -> None:
logger.info("Соединение с NATS восстановлено, подписки восстановлены клиентом")
async def _on_closed(self) -> None:
logger.info("Соединение с NATS закрыто")