NATS: подписка, публикация и /healthcheck.subjects по настройкам; roundtrip на своих subject'ах (#31)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-10-13 12:40:34 +04:00
parent f794ebabcb
commit 1d059993f0
9 changed files with 106 additions and 18 deletions

View File

@ -382,7 +382,7 @@ cd api && .venv/bin/python run_server.py
curl -s localhost:8000/healthcheck curl -s localhost:8000/healthcheck
``` ```
В ответе — `uptime`, `version` и `nats`: `ok` — подключён, `down` — включён (`NATS_ENABLED=true`), но связи нет, В ответе — `uptime`, `version`, `subjects` (используемые subject'ы: `tabular_request`, `result`; JWT там нет) и `nats`: `ok` — подключён, `down` — включён (`NATS_ENABLED=true`), но связи нет,
`off` — выключен (`NATS_ENABLED=false`, воркер не запущен). `off` — выключен (`NATS_ENABLED=false`, воркер не запущен).
Если NATS или MinIO недоступны при старте, приложение всё равно поднимается: ошибка логируется, соединение с NATS Если NATS или MinIO недоступны при старте, приложение всё равно поднимается: ошибка логируется, соединение с NATS
@ -468,6 +468,9 @@ ENV_FILE=.env.test .venv/bin/python -m src.parser.cli <файл> [--file-type XL
| `OPENBAO__NATS__NATS_URL` | — (обязательна) | адрес NATS, `nats://host:4222` | | `OPENBAO__NATS__NATS_URL` | — (обязательна) | адрес NATS, `nats://host:4222` |
| `OPENBAO__NATS__NATS_SEED` | пусто | nkeys seed; пусто вместе с JWT — подключение без аутентификации (dev) | | `OPENBAO__NATS__NATS_SEED` | пусто | nkeys seed; пусто вместе с JWT — подключение без аутентификации (dev) |
| `OPENBAO__NATS__NATS_JWT` | пусто | JWT пользователя NATS | | `OPENBAO__NATS__NATS_JWT` | пусто | JWT пользователя NATS |
| `OPENBAO__NATS__NATS_TABULAR_REQUEST_SUBJECT` | `files.tabular_uploaded` | subject запроса `tabular`, на который подписан сервис |
| `OPENBAO__NATS__NATS_IMAGE_REQUEST_SUBJECT` | `files.img_uploaded` | subject запроса `image` (слушает scan-gateway; здесь — единый набор ключей) |
| `OPENBAO__NATS__NATS_RESULT_SUBJECT` | `files.tabular_parsed` | subject, в который сервис публикует ack и результат |
| `DEBUG` | `false` | уровень логов DEBUG | | `DEBUG` | `false` | уровень логов DEBUG |
| `ENV_FILE` | `.env` | путь к файлу окружения (от `api/`) | | `ENV_FILE` | `.env` | путь к файлу окружения (от `api/`) |
| `NATS_ENABLED` | `true` | `false` — не подключаться к NATS, воркер не стартует | | `NATS_ENABLED` | `true` | `false` — не подключаться к NATS, воркер не стартует |

View File

@ -27,6 +27,11 @@ OPENBAO__MINIO__MINIO_SECURE=false
OPENBAO__NATS__NATS_URL=nats://localhost:4222 OPENBAO__NATS__NATS_URL=nats://localhost:4222
OPENBAO__NATS__NATS_SEED= OPENBAO__NATS__NATS_SEED=
OPENBAO__NATS__NATS_JWT= OPENBAO__NATS__NATS_JWT=
# Subject'ы (необязательно; значения ниже — по умолчанию). Запрос tabular — подписка, результат — публикация.
# Image-запрос слушает scan-gateway; здесь ключ нужен только для единого набора OPENBAO__NATS__*
# OPENBAO__NATS__NATS_TABULAR_REQUEST_SUBJECT=files.tabular_uploaded
# OPENBAO__NATS__NATS_IMAGE_REQUEST_SUBJECT=files.img_uploaded
# OPENBAO__NATS__NATS_RESULT_SUBJECT=files.tabular_parsed
# Необязательные настройки (значения ниже — значения по умолчанию из src/core/config.py) # Необязательные настройки (значения ниже — значения по умолчанию из src/core/config.py)
# Префикс путей приложения за прокси и версия в OpenAPI # Префикс путей приложения за прокси и версия в OpenAPI

View File

@ -2,7 +2,7 @@
Что здесь есть: настройки, подключение S3 (MinIO), NATS (`src/core/nats.py`: nkeys JWT, пустые seed/jwt — без Что здесь есть: настройки, подключение S3 (MinIO), NATS (`src/core/nats.py`: nkeys JWT, пустые seed/jwt — без
аутентификации, фоновое подключение — старт не ждёт NATS), воркер `src/core/worker.py` (подписка на аутентификации, фоновое подключение — старт не ждёт NATS), воркер `src/core/worker.py` (подписка на
`files.tabular_uploaded` после подключения к NATS), middleware (gzip, proxy headers), `/healthcheck` subject запроса `tabular` из настроек после подключения к NATS), middleware (gzip, proxy headers), `/healthcheck`
(`nats = ok | down | off`, R-17), обработчики исключений. Сервис без БД и фронта (DECISION-4). (`nats = ok | down | off`, R-17), обработчики исключений. Сервис без БД и фронта (DECISION-4).
При `NATS_ENABLED=false` ни NATS, ни воркер не запускаются. Остановка: воркер перестаёт принимать запросы и При `NATS_ENABLED=false` ни NATS, ни воркер не запускаются. Остановка: воркер перестаёт принимать запросы и
@ -24,7 +24,7 @@ from src.core.clients import S3Storage
from src.core.config import get_settings from src.core.config import get_settings
from src.core.exception_handlers import register_exception_handlers from src.core.exception_handlers import register_exception_handlers
from src.core.logger import setup_logger from src.core.logger import setup_logger
from src.core.nats import NatsConnection from src.core.nats import NatsConnection, used_subjects
from src.core.worker import STOP_TIMEOUT, RecognitionWorker from src.core.worker import STOP_TIMEOUT, RecognitionWorker
from src.parser import get_parser from src.parser import get_parser
@ -85,12 +85,14 @@ def _build_worker(storage: S3Storage) -> tuple[NatsConnection, RecognitionWorker
service_version=VERSION, service_version=VERSION,
concurrency=settings.WORKER_CONCURRENCY, concurrency=settings.WORKER_CONCURRENCY,
done_cache_size=settings.DONE_CACHE_SIZE, done_cache_size=settings.DONE_CACHE_SIZE,
result_subject=settings.OPENBAO.NATS.NATS_RESULT_SUBJECT,
) )
return nats, worker return nats, worker
@asynccontextmanager @asynccontextmanager
async def lifespan(app: FastAPI): async def lifespan(app: FastAPI):
logger.info("Subject'ы NATS. {subjects}", subjects=used_subjects(settings.OPENBAO.NATS))
storage = await _connect_s3() storage = await _connect_s3()
nats: NatsConnection | None = None nats: NatsConnection | None = None
worker: RecognitionWorker | None = None worker: RecognitionWorker | None = None
@ -130,7 +132,12 @@ def _nats_state(application: FastAPI) -> str:
@app.get("/healthcheck", status_code=200) @app.get("/healthcheck", status_code=200)
async def healthcheck(request: Request): async def healthcheck(request: Request):
return {"uptime": UP_TIME, "version": VERSION, "nats": _nats_state(request.app)} return {
"uptime": UP_TIME,
"version": VERSION,
"nats": _nats_state(request.app),
"subjects": used_subjects(settings.OPENBAO.NATS),
}
app.include_router(router) app.include_router(router)

View File

@ -10,7 +10,8 @@
завершился исключением, supervisor повторяет попытку с паузой 1 → 2 → 4 → … ≤ 30 с. После подключения один раз завершился исключением, supervisor повторяет попытку с паузой 1 → 2 → 4 → … ≤ 30 с. После подключения один раз
вызывается `on_connected(conn)` (подписки воркера); если он упал, созданные в этой попытке подписки снимаются и вызывается `on_connected(conn)` (подписки воркера); если он упал, созданные в этой попытке подписки снимаются и
вызов повторяется с той же паузой. Подписки после разрыва nats-py восстанавливает сам. вызов повторяется с той же паузой. Подписки после разрыва nats-py восстанавливает сам.
- `subscribe_requests(cb)` — подписка на `files.tabular_uploaded` в queue group `tabular-parser`: из нескольких - `subscribe_requests(cb)` — подписка на subject запроса `tabular` из настроек (по умолчанию
`files.tabular_uploaded`) в queue group `tabular-parser`: из нескольких
инстанций сообщение получает одна. После `subscribe` — `flush`: сервер обработал SUB, ошибки соединения проявляются инстанций сообщение получает одна. После `subscribe` — `flush`: сервер обработал SUB, ошибки соединения проявляются
здесь. Отказ в правах (`Permissions Violation`) nats-py 2.14 так не проявляет: соединение остаётся открытым, здесь. Отказ в правах (`Permissions Violation`) nats-py 2.14 так не проявляет: соединение остаётся открытым,
`subscribe`/`flush`/`publish` проходят, сервер лишь присылает `-ERR` в `error_cb` — он логируется отдельно уровнем `subscribe`/`flush`/`publish` проходят, сервер лишь присылает `-ERR` в `error_cb` — он логируется отдельно уровнем
@ -37,7 +38,7 @@ from loguru import logger
from nats.aio.client import Client as NATS from nats.aio.client import Client as NATS
from nats.aio.msg import Msg from nats.aio.msg import Msg
from src.contract.subjects import NATS_MSG_ID_HEADER, TABULAR_REQUEST_SUBJECT from src.contract.subjects import NATS_MSG_ID_HEADER
from src.core.config import NATSSettings from src.core.config import NATSSettings
REQUEST_QUEUE_GROUP = "tabular-parser" REQUEST_QUEUE_GROUP = "tabular-parser"
@ -112,6 +113,11 @@ def build_nats_auth(settings: NATSSettings) -> dict[str, Any]:
return {"user_jwt_cb": user_jwt_cb, "signature_cb": signature_cb} return {"user_jwt_cb": user_jwt_cb, "signature_cb": signature_cb}
def used_subjects(settings: NATSSettings) -> dict[str, str]:
"""Subject'ы сервиса для лога старта и `/healthcheck.subjects`: подписан на запрос `tabular`, публикует ответ."""
return {"tabular_request": settings.NATS_TABULAR_REQUEST_SUBJECT, "result": settings.NATS_RESULT_SUBJECT}
class NatsConnection: class NatsConnection:
"""Соединение сервиса с NATS: фоновое подключение, подписка на запросы, публикация с повторами. """Соединение сервиса с NATS: фоновое подключение, подписка на запросы, публикация с повторами.
@ -276,9 +282,13 @@ class NatsConnection:
# --- подписка и публикация ------------------------------------------------------------------ # --- подписка и публикация ------------------------------------------------------------------
async def subscribe_requests( async def subscribe_requests(
self, cb: MessageHandler, *, queue: str = REQUEST_QUEUE_GROUP, subject: str = TABULAR_REQUEST_SUBJECT self, cb: MessageHandler, *, queue: str = REQUEST_QUEUE_GROUP, subject: str | None = None
) -> Any: ) -> Any:
"""Подписка на запросы (`files.tabular_uploaded`) в queue group; исключения `cb` логируются.""" """Подписка на запросы в queue group; исключения `cb` логируются.
Без `subject` — `NATS_TABULAR_REQUEST_SUBJECT` настроек (по умолчанию `files.tabular_uploaded`).
"""
subject = subject or self._settings.NATS_TABULAR_REQUEST_SUBJECT
if not self.is_connected: if not self.is_connected:
raise NatsNotConnectedError(f"Нет соединения с NATS: подписка на {subject} невозможна") raise NatsNotConnectedError(f"Нет соединения с NATS: подписка на {subject} невозможна")

View File

@ -141,11 +141,13 @@ class RecognitionWorker:
concurrency: int = 1, concurrency: int = 1,
done_cache_size: int = 1000, done_cache_size: int = 1000,
clock: Clock = time.monotonic, clock: Clock = time.monotonic,
result_subject: str = RESULT_SUBJECT,
) -> None: ) -> None:
if concurrency < 1 or done_cache_size < 1: if concurrency < 1 or done_cache_size < 1:
raise ValueError("concurrency и done_cache_size должны быть ≥ 1") raise ValueError("concurrency и done_cache_size должны быть ≥ 1")
self._storage = storage self._storage = storage
self._publisher = publisher self._publisher = publisher
self._result_subject = result_subject
self._parser = parser self._parser = parser
self._service = ServiceInfo.model_validate({"name": service_name, "version": service_version}) self._service = ServiceInfo.model_validate({"name": service_name, "version": service_version})
self._concurrency = concurrency self._concurrency = concurrency
@ -464,7 +466,7 @@ class RecognitionWorker:
"""Публикация конверта; исчерпанные повторы и прочие сбои — error-лог, не исключение (D-5).""" """Публикация конверта; исчерпанные повторы и прочие сбои — error-лог, не исключение (D-5)."""
payload = json.dumps(envelope.to_wire(), ensure_ascii=False).encode("utf-8") payload = json.dumps(envelope.to_wire(), ensure_ascii=False).encode("utf-8")
try: try:
await self._publisher.publish(RESULT_SUBJECT, payload, msg_id) await self._publisher.publish(self._result_subject, payload, msg_id)
except NatsPublishError as exc: except NatsPublishError as exc:
logger.error( logger.error(
"Конверт не опубликован после всех повторов; ядро закроет задание по reply_deadline. " "Конверт не опубликован после всех повторов; ядро закроет задание по reply_deadline. "

View File

@ -12,7 +12,8 @@ Spec tabular-recognition-service: «Ack раньше результата», «
«Реальная ОСВ через воркер» (маркер `fixtures`: реальный XLS из `../fixtures` → `done` и файл результата в MinIO), «Реальная ОСВ через воркер» (маркер `fixtures`: реальный XLS из `../fixtures` → `done` и файл результата в MinIO),
«Реальные формы через воркер» (книга НПЗ → `done`, документы BALANCE и INCOME, `result_schema_version 1.2`). «Реальные формы через воркер» (книга НПЗ → `done`, документы BALANCE и INCOME, `result_schema_version 1.2`).
Изоляция: `job_id`/`file_id`/ключ S3 уникальны на прогон (uuid4), исходник удаляется после теста; на шине может Изоляция: subject'ы прогона уникальны — контейнер стенда сообщения теста не получает (OQ-83);
`job_id`/`file_id`/ключ S3 уникальны на прогон (uuid4), исходник удаляется после теста; на шине может
работать другой сервис — сообщения `files.tabular_parsed` фильтруются по своему `job_id`. Ожидания — по событиям с работать другой сервис — сообщения `files.tabular_parsed` фильтруются по своему `job_id`. Ожидания — по событиям с
таймаутом; единственная фиксированная пауза — явное окно тишины `QUIET_WINDOW` для отрицательных проверок. таймаутом; единственная фиксированная пауза — явное окно тишины `QUIET_WINDOW` для отрицательных проверок.
""" """
@ -37,8 +38,6 @@ from result_contract import schema_errors
from src.contract import ( from src.contract import (
NATS_MSG_ID_HEADER, NATS_MSG_ID_HEADER,
RESULT_SUBJECT,
TABULAR_REQUEST_SUBJECT,
RecognitionResult, RecognitionResult,
request_msg_id, request_msg_id,
) )
@ -71,6 +70,14 @@ SOURCE_BYTES = b"PK\x03\x04 round-trip: broken zip, the default parser answers U
XLSX_CONTENT_TYPE = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" XLSX_CONTENT_TYPE = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
XLS_CONTENT_TYPE = "application/vnd.ms-excel" XLS_CONTENT_TYPE = "application/vnd.ms-excel"
# Subject'ы прогона уникальны (OQ-83): контейнер tabular-parser стенда слушает умолчания и сообщения теста не получает
RUN = uuid.uuid4().hex[:8]
TEST_SUBJECTS = {
"NATS_TABULAR_REQUEST_SUBJECT": f"test-{RUN}.files.tabular_uploaded",
"NATS_IMAGE_REQUEST_SUBJECT": f"test-{RUN}.files.img_uploaded",
"NATS_RESULT_SUBJECT": f"test-{RUN}.files.tabular_parsed",
}
app_module = importlib.import_module("src.app") # модуль, а не объект `app`, который реэкспортирует пакет `src` app_module = importlib.import_module("src.app") # модуль, а не объект `app`, который реэкспортирует пакет `src`
@ -136,7 +143,7 @@ class Core:
self._inboxes.setdefault(request["job_id"], asyncio.Queue()) self._inboxes.setdefault(request["job_id"], asyncio.Queue())
payload = json.dumps(request, ensure_ascii=False).encode("utf-8") payload = json.dumps(request, ensure_ascii=False).encode("utf-8")
headers = {NATS_MSG_ID_HEADER: request_msg_id(request["job_id"], request["attempt"])} headers = {NATS_MSG_ID_HEADER: request_msg_id(request["job_id"], request["attempt"])}
await self.client.publish(TABULAR_REQUEST_SUBJECT, payload, headers=headers) await self.client.publish(TEST_SUBJECTS["NATS_TABULAR_REQUEST_SUBJECT"], payload, headers=headers)
await self.client.flush() await self.client.flush()
async def next_message(self, request: dict[str, Any]) -> Msg: async def next_message(self, request: dict[str, Any]) -> Msg:
@ -216,6 +223,8 @@ async def uploads() -> AsyncIterator[Uploads]:
async def service(monkeypatch: pytest.MonkeyPatch) -> AsyncIterator[None]: async def service(monkeypatch: pytest.MonkeyPatch) -> AsyncIterator[None]:
"""Приложение в процессе теста: lifespan с `NATS_ENABLED=True` (в `.env.test` — false); выход — остановка.""" """Приложение в процессе теста: lifespan с `NATS_ENABLED=True` (в `.env.test` — false); выход — остановка."""
monkeypatch.setattr(app_module.settings, "NATS_ENABLED", True) monkeypatch.setattr(app_module.settings, "NATS_ENABLED", True)
nats_settings = app_module.settings.OPENBAO.NATS
monkeypatch.setattr(app_module.settings.OPENBAO, "NATS", nats_settings.model_copy(update=TEST_SUBJECTS))
application = app_module.app application = app_module.app
tasks: list[asyncio.Task[None]] = [] tasks: list[asyncio.Task[None]] = []
async with application.router.lifespan_context(application): async with application.router.lifespan_context(application):
@ -236,7 +245,7 @@ async def core(service: None) -> AsyncIterator[Core]:
client = NATS() client = NATS()
await client.connect(get_settings().OPENBAO.NATS.NATS_URL, connect_timeout=2, max_reconnect_attempts=1) await client.connect(get_settings().OPENBAO.NATS.NATS_URL, connect_timeout=2, max_reconnect_attempts=1)
core = Core(client) core = Core(client)
await client.subscribe(RESULT_SUBJECT, cb=core.on_result) await client.subscribe(TEST_SUBJECTS["NATS_RESULT_SUBJECT"], cb=core.on_result)
await client.flush() await client.flush()
try: try:
yield core yield core

View File

@ -124,6 +124,11 @@ def patched_app(monkeypatch: pytest.MonkeyPatch, events: list[str]) -> Iterator[
yield app_module yield app_module
def _expected_subjects(module: Any) -> dict[str, str]:
nats = module.settings.OPENBAO.NATS
return {"tabular_request": nats.NATS_TABULAR_REQUEST_SUBJECT, "result": nats.NATS_RESULT_SUBJECT}
async def test_nats_disabled_starts_nothing_and_healthcheck_reports_off( async def test_nats_disabled_starts_nothing_and_healthcheck_reports_off(
patched_app: Any, monkeypatch: pytest.MonkeyPatch, events: list[str] patched_app: Any, monkeypatch: pytest.MonkeyPatch, events: list[str]
) -> None: ) -> None:
@ -136,7 +141,8 @@ async def test_nats_disabled_starts_nothing_and_healthcheck_reports_off(
assert response.status_code == 200 # noqa: PLR2004 - HTTP 200 assert response.status_code == 200 # noqa: PLR2004 - HTTP 200
body = response.json() body = response.json()
assert set(body) == {"uptime", "version", "nats"} assert set(body) == {"uptime", "version", "nats", "subjects"}
assert body["subjects"] == _expected_subjects(patched_app)
assert body["nats"] == "off" assert body["nats"] == "off"
assert body["version"] == patched_app.settings.VERSION assert body["version"] == patched_app.settings.VERSION
assert FakeNatsConnection.instances == [] assert FakeNatsConnection.instances == []
@ -154,7 +160,7 @@ async def test_minimal_environment_starts_and_healthcheck_answers(
response = await client.get("/healthcheck") response = await client.get("/healthcheck")
assert response.status_code == 200 # noqa: PLR2004 - HTTP 200 assert response.status_code == 200 # noqa: PLR2004 - HTTP 200
assert set(response.json()) == {"uptime", "version", "nats"} assert set(response.json()) == {"uptime", "version", "nats", "subjects"}
async def test_nats_enabled_subscribes_worker_and_reports_ok( async def test_nats_enabled_subscribes_worker_and_reports_ok(
@ -195,7 +201,10 @@ async def test_nats_enabled_but_not_connected_reports_down(patched_app: Any, mon
FakeNatsConnection.connect_on_start = False FakeNatsConnection.connect_on_start = False
async with running_app(patched_app) as client: async with running_app(patched_app) as client:
assert (await client.get("/healthcheck")).json()["nats"] == "down" response = await client.get("/healthcheck")
assert response.json()["nats"] == "down"
assert response.json()["subjects"] == _expected_subjects(patched_app)
assert "jwt" not in response.text.lower()
(connection,) = FakeNatsConnection.instances (connection,) = FakeNatsConnection.instances
assert connection.handlers == [] # подписка — только после подключения assert connection.handlers == [] # подписка — только после подключения
assert patched_app.app.state.worker is not None assert patched_app.app.state.worker is not None

View File

@ -26,6 +26,7 @@ from src.core.nats import (
NatsPublishError, NatsPublishError,
Publisher, Publisher,
build_nats_auth, build_nats_auth,
used_subjects,
) )
from src.core.worker import STOP_TIMEOUT from src.core.worker import STOP_TIMEOUT
@ -347,6 +348,26 @@ async def test_subscribe_requests_uses_tabular_subject_and_queue_group():
await conn.stop() await conn.stop()
async def test_subscribe_requests_default_subject_comes_from_settings():
world = _FakeWorld()
settings = NATSSettings(
NATS_URL="nats://localhost:4222", NATS_TABULAR_REQUEST_SUBJECT="test.files.tabular_uploaded"
)
conn = NatsConnection(settings, client_factory=world, sleep=_RecordingSleep())
await conn.start()
assert await conn.wait_ready(1)
await conn.subscribe_requests(_noop_handler)
assert world.clients[-1].subscriptions[0]["subject"] == "test.files.tabular_uploaded"
await conn.stop()
def test_used_subjects_tabular_parser() -> None:
nats = NATSSettings(NATS_URL="nats://x:4222", NATS_TABULAR_REQUEST_SUBJECT="test.files.tabular_uploaded")
assert used_subjects(nats) == {"tabular_request": "test.files.tabular_uploaded", "result": "files.tabular_parsed"}
async def test_subscribe_requests_flushes_after_subscribe(): async def test_subscribe_requests_flushes_after_subscribe():
"""T8.3: подписка считается активной, когда сервер обработал SUB (flush после subscribe).""" """T8.3: подписка считается активной, когда сервер обработал SUB (flush после subscribe)."""
world = _FakeWorld() world = _FakeWorld()

View File

@ -174,7 +174,14 @@ def log_records() -> Iterator[list[Any]]:
class Env: class Env:
def __init__(self, parser: Any = None, *, concurrency: int = 1, done_cache_size: int = 1000) -> None: def __init__(
self,
parser: Any = None,
*,
concurrency: int = 1,
done_cache_size: int = 1000,
result_subject: str | None = None,
) -> None:
self.events: list[tuple[str, str]] = [] self.events: list[tuple[str, str]] = []
self.storage = FakeStorage(self.events) self.storage = FakeStorage(self.events)
self.storage.objects[(BUCKET, SOURCE_KEY)] = b"PK\x03\x04 xlsx bytes" self.storage.objects[(BUCKET, SOURCE_KEY)] = b"PK\x03\x04 xlsx bytes"
@ -188,6 +195,7 @@ class Env:
concurrency=concurrency, concurrency=concurrency,
done_cache_size=done_cache_size, done_cache_size=done_cache_size,
clock=FakeClock(), clock=FakeClock(),
**({} if result_subject is None else {"result_subject": result_subject}),
) )
def bodies(self) -> list[dict[str, Any]]: def bodies(self) -> list[dict[str, Any]]:
@ -217,6 +225,20 @@ def _assert_message_valid(body: dict[str, Any]) -> None:
RecognitionResultMessage.model_validate(body) RecognitionResultMessage.model_validate(body)
async def test_ack_and_result_go_to_configured_result_subject() -> None:
env = await _make_env(result_subject="test.files.tabular_parsed")
try:
await env.worker.on_request(_msg(_request()))
await env.worker.join()
finally:
await env.worker.stop(timeout=1)
assert [(subject, msg_id) for subject, _body, msg_id in env.publisher.published] == [
("test.files.tabular_parsed", f"ack:{JOB_ID}:1"),
("test.files.tabular_parsed", f"result:{JOB_ID}:1"),
]
# --- ack раньше результата ------------------------------------------------------------------------------------------ # --- ack раньше результата ------------------------------------------------------------------------------------------
async def test_ack_is_published_before_s3_read_and_result(env: Env) -> None: async def test_ack_is_published_before_s3_read_and_result(env: Env) -> None:
await env.worker.on_request(_msg(_request())) await env.worker.on_request(_msg(_request()))