Срез 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 (адаптер ИС Сканирование). Без БД и без фронта.
Что здесь есть: настройки, регистрация SMB-сессии (только при `SCAN_HOTFOLDER_MODE=smb`), middleware (gzip, proxy
headers), обработчики ошибок, `GET /healthcheck` (`uptime`, `version`).
Чего здесь пока нет: NATS-воркера и S3-клиента (подключаются в lifespan задачами 5.2/5.3 change slice-0-skeleton),
полей `nats`/`hotfolder` в healthcheck.
- `create_app(settings)` строит горячую папку (`create_hotfolder`, без обращения к ФС/шаре) и `HealthProvider`;
`GET /healthcheck` отдаёт `uptime`, `version`, `nats` (`ok`/`down`/`disabled`), `hotfolder` (`ok`/`down`).
- lifespan: регистрация SMB-сессии (только `SCAN_HOTFOLDER_MODE=smb`), S3 (`S3Storage`), NATS (`NatsGateway`), воркер
(`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
import smbclient
@ -16,8 +22,14 @@ from starlette.middleware.gzip import GZipMiddleware
from uvicorn.middleware.proxy_headers import ProxyHeadersMiddleware
from src.api.router import router
from src.core.clients import S3Storage
from src.core.config import Settings, get_settings
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:
@ -29,10 +41,35 @@ def _normalize_root_path(value: str) -> str:
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:
settings = settings or get_settings()
version = settings.VERSION
up_time = datetime.now(UTC)
hotfolder = create_hotfolder(settings)
health = HealthProvider(nats_enabled=settings.NATS_ENABLED, hotfolder=hotfolder)
@asynccontextmanager
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)
smb_session = True
except Exception:
# учётные данные передаются в каждый вызов smbclient (SmbHotFolder), сессия восстановится при
# доступной шаре; пока шара недоступна, /healthcheck.hotfolder = down
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:
logger.info("Приложение запущено")
yield
finally:
logger.info("Приложение закрывается")
nats_task.cancel()
with suppress(asyncio.CancelledError):
await nats_task
await worker.stop()
await gateway.close()
await storage.close()
if smb_session:
smbclient.reset_connection_cache()
app = FastAPI(title="scan-gateway", version=version, root_path=settings.ROOT_PATH, lifespan=lifespan)
app.state.health = health
@app.get("/healthcheck", status_code=200)
async def healthcheck():
return {"uptime": up_time, "version": version}
return {"uptime": up_time, "version": version, **await health.snapshot()}
app.include_router(router)
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 pathlib import Path
from typing import Any
from unittest.mock import MagicMock
import httpx
import pytest
import smbclient
from src.app import create_app
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:
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())
# lifespan не запускается: подключений к NATS/S3 нет
@ -21,13 +49,42 @@ async def test_healthcheck_returns_uptime_and_version_without_infrastructure(mon
assert response.status_code == 200
body = response.json()
assert set(body) == {"uptime", "version"}
assert set(body) == {"uptime", "version", "nats", "hotfolder"}
assert body["version"] == get_settings().VERSION
datetime.fromisoformat(body["uptime"])
async def test_lifespan_does_not_register_smb_session_in_local_mode(monkeypatch):
monkeypatch.setenv("SCAN_HOTFOLDER_MODE", "local")
async def test_start_in_local_mode(local_env: Path) -> None:
"""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()
monkeypatch.setattr(smbclient, "register_session", register)
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()
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("OPENBAO__SMB__SMB_HOST", "smb.example")
monkeypatch.setenv("OPENBAO__SMB__SMB_PATH", "share")
monkeypatch.setenv("OPENBAO__SMB__SMB_LOGIN", "user")
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()
reset = MagicMock()
monkeypatch.setattr(smbclient, "register_session", register)
monkeypatch.setattr(smbclient, "reset_connection_cache", reset)
monkeypatch.setattr(smbclient, "listdir", MagicMock(return_value=["Input", "Output"]))
app = create_app(get_settings())
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")
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.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`
- [ ] `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] Тест параллелизма

View File

@ -50,7 +50,7 @@
- [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` и повторами);
проверка: 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` →
результат/конверт → publish; таблица ошибок D-7; `/healthcheck.nats`, `.hotfolder`; проверка: unit-тесты `handle` с
фейками и `LocalHotFolder` + `process_once` эмулятора — сценарии spec (ack раньше файла, повторный запрос → один файл,