Andrey Epifancev 09f0bb32e2 Срез 0 [5.3]: /healthcheck.nats и .hotfolder, воркер и NATS в lifespan, отметка задачи
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>
2026-10-04 03:50:22 +04:00

97 lines
4.6 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.

"""Состояние сервиса для `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()