Срез 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>
This commit is contained in:
Andrey Epifancev 2026-10-04 03:50:22 +04:00
parent ea49abd03b
commit 09f0bb32e2
6 changed files with 431 additions and 15 deletions

View File

@ -1,12 +1,18 @@
"""Точка входа FastAPI сервиса scan-gateway (адаптер ИС Сканирование). Без БД и без фронта. """Точка входа FastAPI сервиса scan-gateway (адаптер ИС Сканирование). Без БД и без фронта.
Что здесь есть: настройки, регистрация SMB-сессии (только при `SCAN_HOTFOLDER_MODE=smb`), middleware (gzip, proxy - `create_app(settings)` строит горячую папку (`create_hotfolder`, без обращения к ФС/шаре) и `HealthProvider`;
headers), обработчики ошибок, `GET /healthcheck` (`uptime`, `version`). `GET /healthcheck` отдаёт `uptime`, `version`, `nats` (`ok`/`down`/`disabled`), `hotfolder` (`ok`/`down`).
Чего здесь пока нет: NATS-воркера и S3-клиента (подключаются в lifespan задачами 5.2/5.3 change slice-0-skeleton), - lifespan: регистрация SMB-сессии (только `SCAN_HOTFOLDER_MODE=smb`), S3 (`S3Storage`), NATS (`NatsGateway`), воркер
полей `nats`/`hotfolder` в healthcheck. (`Worker` с `NullNormalizer`). Подключение к NATS блокирует до доступности сервера, поэтому идёт фоновой задачей:
HTTP отвечает сразу (`nats = down`), подписка `recognition.image.request` появляется после подключения. Сбой этой
задачи (например, `NatsConfigError`) — лог error с трассировкой и `nats = down`, HTTP-сервер жив.
Остановка: отмена подключения, остановка воркера (задания в работе отменяются), закрытие NATS и S3.
- Объекты lifespan доступны в `app.state`: `worker`, `nats`, `storage`, `health` (тесты, задачи 13–14).
- `NATS_ENABLED=false` — подключения нет (`nats = disabled`), воркер запросов не получает (dev без NATS).
""" """
from contextlib import asynccontextmanager import asyncio
from contextlib import asynccontextmanager, suppress
from datetime import UTC, datetime from datetime import UTC, datetime
import smbclient import smbclient
@ -16,8 +22,14 @@ from starlette.middleware.gzip import GZipMiddleware
from uvicorn.middleware.proxy_headers import ProxyHeadersMiddleware from uvicorn.middleware.proxy_headers import ProxyHeadersMiddleware
from src.api.router import router from src.api.router import router
from src.core.clients import S3Storage
from src.core.config import Settings, get_settings 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.nats import NatsGateway
from src.core.worker import Worker
from src.gateway import create_hotfolder
from src.normalizer import NullNormalizer
def _normalize_root_path(value: str) -> str: def _normalize_root_path(value: str) -> str:
@ -29,10 +41,35 @@ def _normalize_root_path(value: str) -> str:
return "/" + value.strip("/") return "/" + value.strip("/")
async def run_nats(gateway: NatsGateway, worker: Worker) -> None:
"""Фоновая задача: подключение к NATS (ждёт сервер сколь угодно долго) и подписка воркера на запросы.
Ошибка не роняет приложение: лог error с трассировкой; если соединение установлено, а подписка не удалась,
соединение закрывается, чтобы `/healthcheck.nats = down` честно показывал, что запросы не принимаются.
"""
try:
if not await gateway.connect():
return
except asyncio.CancelledError:
raise
except Exception:
logger.exception("NATS: подключение не выполнено, запросы не принимаются (nats=down) до перезапуска")
return
try:
await gateway.subscribe_requests(worker.on_request)
except asyncio.CancelledError:
raise
except Exception:
logger.exception("NATS: подписка на запросы не оформлена, соединение закрывается (nats=down)")
await gateway.close()
def create_app(settings: Settings | None = None) -> FastAPI: def create_app(settings: Settings | None = None) -> FastAPI:
settings = settings or get_settings() settings = settings or get_settings()
version = settings.VERSION version = settings.VERSION
up_time = datetime.now(UTC) up_time = datetime.now(UTC)
hotfolder = create_hotfolder(settings)
health = HealthProvider(nats_enabled=settings.NATS_ENABLED, hotfolder=hotfolder)
@asynccontextmanager @asynccontextmanager
async def lifespan(app: FastAPI): async def lifespan(app: FastAPI):
@ -43,20 +80,38 @@ def create_app(settings: Settings | None = None) -> FastAPI:
smbclient.register_session(smb.SMB_HOST, smb.SMB_LOGIN, smb.SMB_PASSWORD) smbclient.register_session(smb.SMB_HOST, smb.SMB_LOGIN, smb.SMB_PASSWORD)
smb_session = True smb_session = True
except Exception: except Exception:
# учётные данные передаются в каждый вызов smbclient (SmbHotFolder), сессия восстановится при
# доступной шаре; пока шара недоступна, /healthcheck.hotfolder = down
logger.exception("Ошибка при регистрации сессии SMB") logger.exception("Ошибка при регистрации сессии SMB")
storage = S3Storage.from_settings(settings.OPENBAO.MINIO)
gateway = NatsGateway(settings)
worker = Worker(settings, storage=storage, publisher=gateway, hotfolder=hotfolder, normalizer=NullNormalizer())
health.nats = gateway
app.state.storage = storage
app.state.nats = gateway
app.state.worker = worker
worker.start()
nats_task = asyncio.create_task(run_nats(gateway, worker), name="nats-connect")
try: try:
logger.info("Приложение запущено") logger.info("Приложение запущено")
yield yield
finally: finally:
logger.info("Приложение закрывается") logger.info("Приложение закрывается")
nats_task.cancel()
with suppress(asyncio.CancelledError):
await nats_task
await worker.stop()
await gateway.close()
await storage.close()
if smb_session: if smb_session:
smbclient.reset_connection_cache() smbclient.reset_connection_cache()
app = FastAPI(title="scan-gateway", version=version, root_path=settings.ROOT_PATH, lifespan=lifespan) app = FastAPI(title="scan-gateway", version=version, root_path=settings.ROOT_PATH, lifespan=lifespan)
app.state.health = health
@app.get("/healthcheck", status_code=200) @app.get("/healthcheck", status_code=200)
async def healthcheck(): async def healthcheck():
return {"uptime": up_time, "version": version} return {"uptime": up_time, "version": version, **await health.snapshot()}
app.include_router(router) app.include_router(router)
register_exception_handlers(app) register_exception_handlers(app)

96
api/src/core/health.py Normal file
View File

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

View File

@ -1,18 +1,46 @@
"""Приложение: `/healthcheck` (`uptime`, `version`, `nats`, `hotfolder`), lifespan (SMB-сессия, S3, NATS в фоне,
воркер). NATS и S3 не нужны: в тестах lifespan `NATS_ENABLED=false` либо методы `NatsGateway` подменены."""
import asyncio
from datetime import datetime from datetime import datetime
from pathlib import Path
from typing import Any
from unittest.mock import MagicMock from unittest.mock import MagicMock
import httpx import httpx
import pytest
import smbclient import smbclient
from src.app import create_app from src.app import create_app
from src.core.config import get_settings from src.core.config import get_settings
from src.core.nats import NatsGateway
from src.core.worker import Worker
pytestmark = pytest.mark.usefixtures("log_lines")
def _client(app) -> httpx.AsyncClient: def _client(app) -> httpx.AsyncClient:
return httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://test") return httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://test")
async def test_healthcheck_returns_uptime_and_version_without_infrastructure(monkeypatch): @pytest.fixture
def local_env(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
"""Режим local с существующим корнем и без NATS: lifespan не ходит в сеть."""
monkeypatch.setenv("SCAN_HOTFOLDER_MODE", "local")
monkeypatch.setenv("SCAN_HOTFOLDER_PATH", str(tmp_path))
monkeypatch.setenv("NATS_ENABLED", "false")
return tmp_path
async def eventually(predicate, timeout: float = 2.0) -> None:
loop = asyncio.get_running_loop()
deadline = loop.time() + timeout
while not predicate():
assert loop.time() < deadline, "условие не выполнилось вовремя"
await asyncio.sleep(0.005)
async def test_healthcheck_without_lifespan_has_all_fields(local_env: Path) -> None:
app = create_app(get_settings()) app = create_app(get_settings())
# lifespan не запускается: подключений к NATS/S3 нет # lifespan не запускается: подключений к NATS/S3 нет
@ -21,13 +49,42 @@ async def test_healthcheck_returns_uptime_and_version_without_infrastructure(mon
assert response.status_code == 200 assert response.status_code == 200
body = response.json() body = response.json()
assert set(body) == {"uptime", "version"} assert set(body) == {"uptime", "version", "nats", "hotfolder"}
assert body["version"] == get_settings().VERSION assert body["version"] == get_settings().VERSION
datetime.fromisoformat(body["uptime"]) datetime.fromisoformat(body["uptime"])
async def test_lifespan_does_not_register_smb_session_in_local_mode(monkeypatch): async def test_start_in_local_mode(local_env: Path) -> None:
monkeypatch.setenv("SCAN_HOTFOLDER_MODE", "local") """Spec «Старт в режиме локальных папок»: 200 с uptime, version, nats, hotfolder = ok; OPENBAO__SMB__* не нужны."""
app = create_app(get_settings())
async with app.router.lifespan_context(app), _client(app) as client:
response = await client.get("/healthcheck")
assert response.status_code == 200
body = response.json()
assert body["hotfolder"] == "ok"
assert body["nats"] == "disabled"
assert body["version"] == get_settings().VERSION
async def test_missing_hotfolder_root_is_down(local_env: Path, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("SCAN_HOTFOLDER_PATH", str(local_env / "missing"))
app = create_app(get_settings())
async with app.router.lifespan_context(app), _client(app) as client:
assert (await client.get("/healthcheck")).json()["hotfolder"] == "down"
async def test_lifespan_starts_and_stops_worker(local_env: Path) -> None:
app = create_app(get_settings())
async with app.router.lifespan_context(app):
worker = app.state.worker
assert isinstance(worker, Worker)
assert isinstance(app.state.nats, NatsGateway)
# после остановки воркер запросов не принимает
await worker.on_request(type("Msg", (), {"data": b"{}"})())
assert worker.active_jobs == 0
async def test_lifespan_does_not_register_smb_session_in_local_mode(local_env: Path, monkeypatch) -> None:
register = MagicMock() register = MagicMock()
monkeypatch.setattr(smbclient, "register_session", register) monkeypatch.setattr(smbclient, "register_session", register)
app = create_app(get_settings()) app = create_app(get_settings())
@ -38,26 +95,114 @@ async def test_lifespan_does_not_register_smb_session_in_local_mode(monkeypatch)
register.assert_not_called() register.assert_not_called()
async def test_lifespan_registers_smb_session_in_smb_mode(monkeypatch): def _smb_env(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("SCAN_HOTFOLDER_MODE", "smb") monkeypatch.setenv("SCAN_HOTFOLDER_MODE", "smb")
monkeypatch.setenv("OPENBAO__SMB__SMB_HOST", "smb.example") monkeypatch.setenv("OPENBAO__SMB__SMB_HOST", "smb.example")
monkeypatch.setenv("OPENBAO__SMB__SMB_PATH", "share") monkeypatch.setenv("OPENBAO__SMB__SMB_PATH", "share")
monkeypatch.setenv("OPENBAO__SMB__SMB_LOGIN", "user") monkeypatch.setenv("OPENBAO__SMB__SMB_LOGIN", "user")
monkeypatch.setenv("OPENBAO__SMB__SMB_PASSWORD", "secret") monkeypatch.setenv("OPENBAO__SMB__SMB_PASSWORD", "secret")
monkeypatch.setenv("NATS_ENABLED", "false")
async def test_lifespan_registers_smb_session_in_smb_mode(monkeypatch) -> None:
_smb_env(monkeypatch)
register = MagicMock() register = MagicMock()
reset = MagicMock() reset = MagicMock()
monkeypatch.setattr(smbclient, "register_session", register) monkeypatch.setattr(smbclient, "register_session", register)
monkeypatch.setattr(smbclient, "reset_connection_cache", reset) monkeypatch.setattr(smbclient, "reset_connection_cache", reset)
monkeypatch.setattr(smbclient, "listdir", MagicMock(return_value=["Input", "Output"]))
app = create_app(get_settings()) app = create_app(get_settings())
async with app.router.lifespan_context(app), _client(app) as client: async with app.router.lifespan_context(app), _client(app) as client:
assert (await client.get("/healthcheck")).status_code == 200 response = await client.get("/healthcheck")
assert response.status_code == 200
assert response.json()["hotfolder"] == "ok"
register.assert_called_once_with("smb.example", "user", "secret") register.assert_called_once_with("smb.example", "user", "secret")
reset.assert_called_once() reset.assert_called_once()
async def test_unhandled_error_has_json_500(monkeypatch): async def test_smb_session_failure_shows_hotfolder_down(monkeypatch, log_lines: list[str]) -> None:
"""T2.3: регистрация SMB-сессии при старте не удалась (шара недоступна) — `hotfolder = down`, сервис жив."""
_smb_env(monkeypatch)
monkeypatch.setattr(smbclient, "register_session", MagicMock(side_effect=OSError("сервер недоступен")))
monkeypatch.setattr(smbclient, "listdir", MagicMock(side_effect=OSError("сервер недоступен")))
app = create_app(get_settings())
async with app.router.lifespan_context(app), _client(app) as client:
response = await client.get("/healthcheck")
assert response.status_code == 200
assert response.json()["hotfolder"] == "down"
assert any("SMB" in line for line in log_lines if line.startswith("ERROR|"))
async def test_nats_connects_in_background_and_subscribes_worker(local_env: Path, monkeypatch) -> None:
monkeypatch.setenv("NATS_ENABLED", "true")
subscribed: list[Any] = []
connected = asyncio.Event()
async def fake_connect(self: NatsGateway) -> bool:
await connected.wait() # подключение «висит», а HTTP уже отвечает
return True
async def fake_subscribe(self: NatsGateway, cb: Any, queue: str = "scan-gateway") -> None:
subscribed.append(cb)
async def fake_close(self: NatsGateway) -> None:
return None
monkeypatch.setattr(NatsGateway, "connect", fake_connect)
monkeypatch.setattr(NatsGateway, "subscribe_requests", fake_subscribe)
monkeypatch.setattr(NatsGateway, "close", fake_close)
app = create_app(get_settings())
async with app.router.lifespan_context(app), _client(app) as client:
assert (await client.get("/healthcheck")).json()["nats"] == "down"
connected.set()
await eventually(lambda: bool(subscribed))
assert subscribed == [app.state.worker.on_request]
async def test_nats_connect_hanging_is_cancelled_on_shutdown(local_env: Path, monkeypatch) -> None:
monkeypatch.setenv("NATS_ENABLED", "true")
cancelled = asyncio.Event()
async def fake_connect(self: NatsGateway) -> bool:
try:
await asyncio.Event().wait()
except asyncio.CancelledError:
cancelled.set()
raise
return True
async def fake_close(self: NatsGateway) -> None:
return None
monkeypatch.setattr(NatsGateway, "connect", fake_connect)
monkeypatch.setattr(NatsGateway, "close", fake_close)
app = create_app(get_settings())
async with app.router.lifespan_context(app):
await asyncio.sleep(0.01)
assert cancelled.is_set()
async def test_nats_config_error_is_loud_and_shows_down(local_env: Path, monkeypatch, log_lines: list[str]) -> None:
"""Неполные учётные данные NATS (`NatsConfigError`): лог error с трассировкой, `nats = down`, HTTP жив."""
monkeypatch.setenv("NATS_ENABLED", "true")
monkeypatch.setenv("OPENBAO__NATS__NATS_SEED", "SUAINCOMPLETESEED")
monkeypatch.setenv("OPENBAO__NATS__NATS_JWT", "")
app = create_app(get_settings())
async with app.router.lifespan_context(app), _client(app) as client:
await eventually(lambda: any(line.startswith("ERROR|") and "NATS" in line for line in log_lines))
response = await client.get("/healthcheck")
assert response.status_code == 200
assert response.json()["nats"] == "down"
async def test_unhandled_error_has_json_500(local_env: Path) -> None:
app = create_app(get_settings()) app = create_app(get_settings())
@app.get("/boom") @app.get("/boom")

View File

@ -0,0 +1,120 @@
"""Состояние для `/healthcheck` (`src.core.health`): поля `nats` и `hotfolder` (spec «Старт в режиме локальных папок»,
«Горячая папка недоступна», «Соединение с NATS без JetStream»; D-8; T8.4 — проверка не зависает, T2.3 — сбой SMB)."""
import threading
from pathlib import Path
import pytest
from src.core.health import HealthProvider
from src.gateway.hotfolder import LocalHotFolder
pytestmark = pytest.mark.usefixtures("log_lines")
class Connection:
def __init__(self, connected: bool) -> None:
self.is_connected = connected
class CountingHotFolder:
"""`healthy()` считает вызовы; может зависнуть до события или бросить исключение."""
def __init__(self, result: bool = True) -> None:
self.result = result
self.calls = 0
self.release: threading.Event | None = None
self.error: BaseException | None = None
def put(self, name: str, data: bytes) -> None:
raise NotImplementedError
def poll(self, job_id: str) -> bytes | None:
raise NotImplementedError
def healthy(self) -> bool:
self.calls += 1
if self.release is not None:
self.release.wait(5)
if self.error is not None:
raise self.error
return self.result
class Clock:
def __init__(self) -> None:
self.now = 1000.0
def __call__(self) -> float:
return self.now
async def test_local_root_exists_is_ok(tmp_path: Path) -> None:
health = HealthProvider(nats_enabled=False, hotfolder=LocalHotFolder(tmp_path))
assert await health.snapshot() == {"nats": "disabled", "hotfolder": "ok"}
async def test_missing_root_is_down(tmp_path: Path) -> None:
health = HealthProvider(nats_enabled=False, hotfolder=LocalHotFolder(tmp_path / "missing"))
assert await health.hotfolder_status() == "down"
async def test_no_hotfolder_is_down() -> None:
assert await HealthProvider(nats_enabled=False, hotfolder=None).hotfolder_status() == "down"
@pytest.mark.parametrize(
("enabled", "connection", "expected"),
[
(False, None, "disabled"),
(False, Connection(True), "disabled"),
(True, None, "down"),
(True, Connection(False), "down"),
(True, Connection(True), "ok"),
],
)
def test_nats_status(enabled: bool, connection: Connection | None, expected: str) -> None:
health = HealthProvider(nats_enabled=enabled, hotfolder=None)
health.nats = connection
assert health.nats_status() == expected
async def test_hotfolder_result_is_cached_for_ttl() -> None:
hotfolder = CountingHotFolder(result=True)
clock = Clock()
health = HealthProvider(nats_enabled=False, hotfolder=hotfolder, cache_ttl=5, clock=clock)
assert await health.hotfolder_status() == "ok"
hotfolder.result = False
clock.now += 4.9
assert await health.hotfolder_status() == "ok"
assert hotfolder.calls == 1
clock.now += 0.2
assert await health.hotfolder_status() == "down"
assert hotfolder.calls == 2
async def test_hanging_check_is_down_within_timeout_and_not_repeated() -> None:
hotfolder = CountingHotFolder()
hotfolder.release = threading.Event()
clock = Clock()
health = HealthProvider(nats_enabled=False, hotfolder=hotfolder, check_timeout=0.05, cache_ttl=5, clock=clock)
try:
assert await health.hotfolder_status() == "down"
clock.now += 10 # кэш истёк, но прежняя проверка ещё висит — новый поток не запускается
assert await health.hotfolder_status() == "down"
assert hotfolder.calls == 1
finally:
hotfolder.release.set()
async def test_check_raising_is_down() -> None:
hotfolder = CountingHotFolder()
hotfolder.error = OSError("smb")
health = HealthProvider(nats_enabled=False, hotfolder=hotfolder)
assert await health.hotfolder_status() == "down"
async def test_snapshot_with_connected_nats(tmp_path: Path) -> None:
health = HealthProvider(nats_enabled=True, hotfolder=LocalHotFolder(tmp_path))
health.nats = Connection(True)
assert await health.snapshot() == {"nats": "ok", "hotfolder": "ok"}

View File

@ -64,7 +64,7 @@
### Task 12: [5.3] `src/core/worker.py` ### Task 12: [5.3] `src/core/worker.py`
- [ ] `src/core/worker.py`: `on_request(msg)`: валидация → LRU `job_id` → ack `processing` → очередь; семафор `SCAN_MAX_INFLIGHT`; `handle(job)`: S3 get → `hotfolder.put` → опрос до дедлайна → `raw/{job_id}.json` → `Normalizer` → результат/конверт → publish; таблица ошибок D-7; `/healthcheck.nats`, `.hotfolder`; проверка: unit-тесты `handle` с фейками и `LocalHotFolder` + `process_once` эмулятора — сценарии spec (ack раньше файла, повторный запрос → один файл, успех → NOT_IMPLEMENTED с `raw_s3_key`, INVALID_DOCUMENT, TIMEOUT с укороченными таймаутами, RECOGNITION_FAILED при отсутствии каталога, SCHEMA_MISMATCH без ack, FILE_READ_ERROR) - [x] `src/core/worker.py`: `on_request(msg)`: валидация → LRU `job_id` → ack `processing` → очередь; семафор `SCAN_MAX_INFLIGHT`; `handle(job)`: S3 get → `hotfolder.put` → опрос до дедлайна → `raw/{job_id}.json` → `Normalizer` → результат/конверт → publish; таблица ошибок D-7; `/healthcheck.nats`, `.hotfolder`; проверка: unit-тесты `handle` с фейками и `LocalHotFolder` + `process_once` эмулятора — сценарии spec (ack раньше файла, повторный запрос → один файл, успех → NOT_IMPLEMENTED с `raw_s3_key`, INVALID_DOCUMENT, TIMEOUT с укороченными таймаутами, RECOGNITION_FAILED при отсутствии каталога, SCHEMA_MISMATCH без ack, FILE_READ_ERROR)
### Task 13: [5.4] Тест параллелизма ### Task 13: [5.4] Тест параллелизма

View File

@ -50,7 +50,7 @@
- [x] 5.2 `src/core/clients.py` (`get_bytes`, `put_json`, `put_bytes`), `src/core/nats.py` (подключение с/без nkeys, - [x] 5.2 `src/core/clients.py` (`get_bytes`, `put_json`, `put_bytes`), `src/core/nats.py` (подключение с/без nkeys,
автопереподключение, `subscribe_requests(queue="scan-gateway", cb)`, `publish` с `Nats-Msg-Id` и повторами); автопереподключение, `subscribe_requests(queue="scan-gateway", cb)`, `publish` с `Nats-Msg-Id` и повторами);
проверка: unit с фейками, интеграционный тест подключения к compose-NATS без JetStream проверка: unit с фейками, интеграционный тест подключения к compose-NATS без JetStream
- [ ] 5.3 `src/core/worker.py`: `on_request(msg)`: валидация → LRU `job_id` → ack `processing` → очередь; семафор - [x] 5.3 `src/core/worker.py`: `on_request(msg)`: валидация → LRU `job_id` → ack `processing` → очередь; семафор
`SCAN_MAX_INFLIGHT`; `handle(job)`: S3 get → `hotfolder.put` → опрос до дедлайна → `raw/{job_id}.json` → `Normalizer` → `SCAN_MAX_INFLIGHT`; `handle(job)`: S3 get → `hotfolder.put` → опрос до дедлайна → `raw/{job_id}.json` → `Normalizer` →
результат/конверт → publish; таблица ошибок D-7; `/healthcheck.nats`, `.hotfolder`; проверка: unit-тесты `handle` с результат/конверт → publish; таблица ошибок D-7; `/healthcheck.nats`, `.hotfolder`; проверка: unit-тесты `handle` с
фейками и `LocalHotFolder` + `process_once` эмулятора — сценарии spec (ack раньше файла, повторный запрос → один файл, фейками и `LocalHotFolder` + `process_once` эмулятора — сценарии spec (ack раньше файла, повторный запрос → один файл,