"""Состояние сервиса для `GET /healthcheck`: поля `nats` и `hotfolder` (D-8; spec image-recognition-gateway). - `nats`: `disabled` при `NATS_ENABLED=false`; `ok`, если соединение установлено (`is_connected`), иначе `down` — в том числе пока идёт первое подключение, во время переподключения и после сбоя фоновой задачи подключения. - `hotfolder`: `HotFolder.healthy()` в потоке (`asyncio.to_thread`) под `wait_for` с коротким таймаутом и с кэшем на короткое время (T8.4: подключение к недоступной SMB-шаре ждёт до 60 с, проба не должна висеть и плодить потоки). Пока прежняя проверка не вернулась, новая не запускается — ответ `down`. В режиме smb сбой регистрации сессии при старте виден естественно: `SmbHotFolder.healthy()` обращается к шаре с теми же учётными данными и при недоступной шаре или неверном логине возвращает `False` (T2.3). """ import asyncio import time from collections.abc import Callable from typing import Literal, Protocol from loguru import logger from src.gateway.hotfolder import HotFolder __all__ = ["HOTFOLDER_CACHE_TTL", "HOTFOLDER_CHECK_TIMEOUT", "ConnectionState", "HealthProvider"] HOTFOLDER_CHECK_TIMEOUT = 2.0 # секунд на `healthy()`; дольше — `down` HOTFOLDER_CACHE_TTL = 5.0 # секунд, в течение которых повторная проба отдаёт прежний результат Status = Literal["ok", "down"] NatsStatus = Literal["ok", "down", "disabled"] class ConnectionState(Protocol): """Источник состояния NATS (`NatsGateway`).""" @property def is_connected(self) -> bool: ... class HealthProvider: """Поля `nats`/`hotfolder` для `/healthcheck`. `nats` присваивается, когда соединение создано (lifespan).""" def __init__( self, *, nats_enabled: bool, hotfolder: HotFolder | None, check_timeout: float = HOTFOLDER_CHECK_TIMEOUT, cache_ttl: float = HOTFOLDER_CACHE_TTL, clock: Callable[[], float] = time.monotonic, ) -> None: self.nats: ConnectionState | None = None self._nats_enabled = nats_enabled self._hotfolder = hotfolder self._check_timeout = check_timeout self._cache_ttl = cache_ttl self._clock = clock self._cached: Status | None = None self._cached_at = 0.0 self._inflight: asyncio.Task[bool] | None = None def nats_status(self) -> NatsStatus: if not self._nats_enabled: return "disabled" if self.nats is not None and self.nats.is_connected: return "ok" return "down" async def hotfolder_status(self) -> Status: if self._hotfolder is None: return "down" if self._cached is not None and self._clock() - self._cached_at < self._cache_ttl: return self._cached if self._inflight is None or self._inflight.done(): self._inflight = asyncio.create_task(asyncio.to_thread(self._hotfolder.healthy)) self._inflight.add_done_callback(_consume_exception) status: Status try: healthy = await asyncio.wait_for(asyncio.shield(self._inflight), self._check_timeout) status = "ok" if healthy is True else "down" except TimeoutError: logger.warning( "Проверка горячей папки не уложилась в {timeout} с: hotfolder=down", timeout=self._check_timeout ) status = "down" except Exception as exc: logger.warning("Проверка горячей папки упала: hotfolder=down. error_type={type}", type=type(exc).__name__) status = "down" self._cached, self._cached_at = status, self._clock() return status async def snapshot(self) -> dict[str, str]: return {"nats": self.nats_status(), "hotfolder": await self.hotfolder_status()} def _consume_exception(task: "asyncio.Task[bool]") -> None: """Исключение пробы, которую никто не дождался (таймаут), не должно попадать в лог asyncio как «не извлечённое».""" if not task.cancelled(): task.exception()