src/core/health.py: HealthProvider — nats ok/down/disabled по is_connected; hotfolder — healthy() в потоке под wait_for (2 с) с кэшем 5 с, зависшая проба не запускается повторно (T8.4). Сбой регистрации SMB-сессии виден естественно: SmbHotFolder.healthy() обращается к шаре с теми же учётными данными (T2.3). app.py: в lifespan S3Storage, NatsGateway, Worker(NullNormalizer); подключение к NATS и подписка — фоновой задачей (connect ждёт сервер), её сбой — logger.exception и nats=down без падения HTTP; остановка отменяет подключение, останавливает воркер, закрывает NATS и S3. Объекты lifespan — в app.state. test_app.py: тесты lifespan переведены на NATS_ENABLED=false и tmp-корень (иначе фоновое подключение шло бы в compose NATS), проверка полей healthcheck расширена nats/hotfolder. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
97 lines
4.6 KiB
Python
97 lines
4.6 KiB
Python
"""Состояние сервиса для `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()
|