feat(exchange): статусы stored_file по ack/ошибке/таймауту/dlq; inbox без ResultMapper (OQ-67)

- services/file_status.py: set_file_status_for_job(session, job, status, *, now, only_from=None) и пакетный
  set_file_status_for_jobs для вахтёра; переход только при current_recognition_job_id = job.id, PROCESSED и
  SUPERSEDED не перезаписываются (D-5, R-5).
- inbox: ack -> файл UPLOADED->PROCESSING; error -> ERROR; done/needs_check -> UPLOADED->PROCESSING (R-4: ack мог
  потеряться), из PROCESSING файл ждёт цикла маппинга; inbox не читает S3 и не создаёт документы.
- вахтёр: UPDATE recognition_job ... RETURNING id и UPDATE stored_file -> ERROR в той же транзакции;
  публикатор при dlq -> файл ERROR.
- удалены ResultMapper, NoopMapper, _map_result, _MapperError и параметр mapper у handle_message; app.py без mapper.
- тесты: статусы файла (ack/error/done/needs_check/без ack/timeout/dlq), чужое задание файл не трогает (Review
  Focus 1), откат статуса файла вместе с транзакцией сообщения; CommittedJobs.make_file_job/add_job/file.
- tasks.md 2.2, шаги Task 4 в плане.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2
This commit is contained in:
Andrey Epifancev 2026-10-05 22:08:40 +04:00
parent 8d2ced835c
commit b7ca7285cc
10 changed files with 548 additions and 168 deletions

View File

@ -10,7 +10,7 @@ middleware не подключается), healthcheck (uptime, version, nats: o
состояние. При NATS_ENABLED=true lifespan запускает фоновые задачи обмена `recognition_job` — outbox-публикатор и
вахтёр сроков (`src/services/recognition_exchange.py`, design D-6) — и останавливает их до закрытия NATS. Сообщения
`recognition.result` (ack и результаты сервисов) обрабатывает inbox `handle_message` того же модуля с фабрикой сессий
приложения и `NoopMapper` (маппинг результата в доменные таблицы — Срез 2).
приложения (inbox меняет задание и статус файла; маппинг результата в доменные таблицы — отдельный цикл, Срез 2c D-1).
Остановка: фоновые задачи обмена → NATS → S3 → SMB → пул соединений БД; каждый шаг независим — ошибка одного
логируется и не отменяет остальные.
"""
@ -42,7 +42,7 @@ from src.core.config import Settings, get_settings
from src.core.exception_handlers import register_exception_handlers
from src.core.nats import MessageHandler, NatsConnection
from src.db.db import async_session_maker, engine, session_transaction
from src.services.recognition_exchange import ExchangeTasks, NoopMapper, handle_message
from src.services.recognition_exchange import ExchangeTasks, handle_message
from src.services.session_audit import SessionAudit
settings = get_settings()
@ -102,8 +102,9 @@ async def _connect_s3() -> S3Storage | None:
def _result_handler() -> MessageHandler:
"""Inbox `recognition.result`: `handle_message` с фабрикой сессий приложения и `NoopMapper` (Срез 0)."""
return partial(handle_message, session_factory=async_session_maker, mapper=NoopMapper())
"""Inbox `recognition.result`: `handle_message` с фабрикой сессий приложения (маппинг результата — отдельный цикл,
Срез 2c design D-1)."""
return partial(handle_message, session_factory=async_session_maker)
async def _subscribe_results(nats: NatsConnection) -> None:

View File

@ -0,0 +1,85 @@
"""Статус `stored_file` по заданию распознавания (spec primary-files «Жизненный цикл статуса файла»; design D-5;
рулинги R-4, R-5; docs/context/05-domain-rules.md §9.2).
Единственное правило перехода: статус файла меняет только его ТЕКУЩЕЕ задание
(`stored_file.current_recognition_job_id = job.id`). Результат, ack, таймаут или dlq старого задания (повторная загрузка
после `SUPERSEDED`, будущий replay) файл не трогает. `PROCESSED` и `SUPERSEDED` — итог маппинга и автозамены — обменом
не перезаписываются.
Функции выполняют `UPDATE` в транзакции вызывающего (inbox, вахтёр, публикатор) — после изменения строки задания:
порядок блокировок «задание → файл» одинаков у всех участников, взаимоблокировок нет. `audit_event` не пишется:
обмен не аудируется (ASSUMPTION(OQ-55)).
"""
import uuid
from collections.abc import Collection, Sequence
from datetime import datetime
from typing import Any, Protocol
from sqlalchemy import CursorResult, update
from sqlalchemy.ext.asyncio import AsyncSession
from src.models.enums import FileStatus
from src.models.files import StoredFile
# Итоги маппинга и автозамены: события обмена их не перезаписывают (design D-5)
PROTECTED_FILE_STATUSES: frozenset[FileStatus] = frozenset({FileStatus.PROCESSED, FileStatus.SUPERSEDED})
class JobRef(Protocol):
"""Что нужно от задания: `recognition_job.id` и `file_id` (ORM `RecognitionJob` или строка выборки)."""
@property
def id(self) -> uuid.UUID: ...
@property
def file_id(self) -> uuid.UUID: ...
def _rowcount(result: Any) -> int:
return result.rowcount if isinstance(result, CursorResult) else 0
async def set_file_status_for_job(
session: AsyncSession,
job: JobRef,
status: FileStatus,
*,
now: datetime,
only_from: Collection[FileStatus] | None = None,
) -> bool:
"""Перевести файл задания `job` в `status` (`updated_at = now`), если задание — текущее для файла и файл не в
`PROCESSED`/`SUPERSEDED`; `only_from` — допустимые исходные статусы (R-5: ack и результат без ack — только из
`UPLOADED`, запоздавший ack не воскрешает `ERROR`). Возвращает `True`, если строка файла изменена."""
stmt = (
update(StoredFile)
.where(
StoredFile.id == job.file_id,
StoredFile.current_recognition_job_id == job.id,
StoredFile.status.not_in(PROTECTED_FILE_STATUSES),
)
.values(status=status, updated_at=now)
.execution_options(synchronize_session=False)
)
if only_from is not None:
stmt = stmt.where(StoredFile.status.in_(list(only_from)))
return _rowcount(await session.execute(stmt)) == 1
async def set_file_status_for_jobs(
session: AsyncSession, job_ids: Sequence[uuid.UUID], status: FileStatus, *, now: datetime
) -> int:
"""Пакетный вариант для вахтёра: файлы, у которых текущее задание из `job_ids` и статус не `PROCESSED`/
`SUPERSEDED`, → `status`. Возвращает число изменённых файлов."""
if not job_ids:
return 0
stmt = (
update(StoredFile)
.where(
StoredFile.current_recognition_job_id.in_(list(job_ids)),
StoredFile.status.not_in(PROTECTED_FILE_STATUSES),
)
.values(status=status, updated_at=now)
.execution_options(synchronize_session=False)
)
return _rowcount(await session.execute(stmt))

View File

@ -4,9 +4,13 @@ docs/context/03-architecture.md, «Контракт обмена через NATS
Outbox (задача 23):
- `publish_pending()` — один проход публикатора: `queued` → первая публикация → `published`; `published` без ack дольше
`ACK_TIMEOUT_SECONDS` → повторная публикация с тем же `Nats-Msg-Id` и тем же `attempt` (счётчик —
`metrics.republish_count`); после `OUTBOX_MAX_REPUBLISH` повторов → `dlq`, `error_code = NO_ACK`. `attempt` не растёт:
обработка не начиналась. Replay (`attempt + 1`) — вне Среза 0.
- `expire_deadlines()` — вахтёр: `processing`/`published` с `reply_deadline < now` → `timeout`, `error_code = TIMEOUT`.
`metrics.republish_count`); после `OUTBOX_MAX_REPUBLISH` повторов → `dlq`, `error_code = NO_ACK`, файл текущего
задания → `ERROR`. `attempt` не растёт: обработка не начиналась. Replay (`attempt + 1`) — вне Среза 2c.
Статусы `stored_file` (Срез 2c; spec primary-files «Жизненный цикл статуса файла», design D-5, рулинги R-4, R-5) —
только через `services/file_status.py` и только для текущего задания файла (`current_recognition_job_id = job.id`).
- `expire_deadlines()` — вахтёр: `processing`/`published` с `reply_deadline < now` → `timeout`, `error_code = TIMEOUT`;
файлы этих заданий (по `current_recognition_job_id`) → `ERROR` в той же транзакции.
- `run_publisher_loop()` / `run_watchdog_loop()` — фоновые циклы; `ExchangeTasks` запускает их в lifespan при
`NATS_ENABLED=true` и останавливает до закрытия NATS.
@ -20,22 +24,25 @@ SKIP LOCKED` → публикация → `UPDATE` → `COMMIT`). Блокиро
параллельный проход (второй инстанс ядра) пропускает заблокированную строку и не публикует её повторно, а после commit
строка уже не подходит под условие выборки.
Обмен не пишет `audit_event` (рулинг R-7, design #20).
Обмен не пишет `audit_event` (рулинг R-7, design #20; ASSUMPTION(OQ-55)).
Inbox (задача 24, рулинги R-12, R-22): `handle_message()` — обработчик подписки `recognition.result`
(`src/core/nats.py`, queue group `arm-core`). Одно сообщение — одна короткая транзакция без сетевого IO:
`SELECT … FOR UPDATE` строки задания (ЖДЁТ блокировку: ack, пришедший, пока публикатор держит строку до своего commit,
применяется после него, а не теряется) → проверки статуса → изменения → `COMMIT`.
- ack (`status = processing`): `published` → `processing`, `started_at`, `service_*`; повторный ack для `processing` —
тихо игнорируется; ack для другого статуса — warning, задание не меняется.
- ack (`status = processing`): `published` → `processing`, `started_at`, `service_*`, файл `UPLOADED → PROCESSING`
(R-5: только из `UPLOADED`); повторный ack для `processing` — тихо игнорируется; ack для другого статуса — warning,
задание и файл не меняются.
- результат (`done | needs_check | error`): дубликат определяется ТОЛЬКО по `recognition_job.result_msg_id ==
Nats-Msg-Id` (at-least-once) — без изменений; задание в финальном статусе (`FINAL_STATUSES`) — warning, без изменений;
иначе (`queued | published | processing`: потерянный ack не блокирует результат) — регистрация файла результата в
`s3_object` (`bucket_role = results`), обновление задания, `ResultMapper.map_result` (Срез 0 — `NoopMapper`; Срез 2
заменит маппингом результата в `document` / `osv_entry` / `financial_statement_*`) и commit.
`s3_object` (`bucket_role = results`), обновление задания, статус файла и commit. `error` → файл `ERROR`;
`done`/`needs_check` → файл `UPLOADED → PROCESSING` (R-4: ack мог потеряться — core NATS без JetStream), из
`PROCESSING` файл не меняется и ждёт цикла маппинга (design D-1). Inbox НЕ читает файл результата и не создаёт
документы: `ResultMapper` удалён (OQ-67), маппинг — отдельный цикл `services/result_mapping.py`.
- Гонка с вахтёром (`expire_deadlines` — один `UPDATE` по статусу) решается блокировкой строки: кто первым взял
строку, тот и задаёт финальный статус; второй видит финальный статус и ничего не меняет.
- Невалидное сообщение, неизвестный `job_id`, ошибка БД или mapper'а — лог без повторов; исключения наружу не выходят.
- Невалидное сообщение, неизвестный `job_id`, ошибка БД — лог без повторов; исключения наружу не выходят.
Задание без применённого результата дойдёт до `timeout` (осознанный компромисс D-6).
"""
@ -49,7 +56,7 @@ from typing import Any, Protocol
from loguru import logger
from pydantic import ValidationError
from sqlalchemy import CursorResult, and_, case, func, literal, or_, select, update
from sqlalchemy import and_, case, func, literal, or_, select, update
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.dialects.postgresql import insert as pg_insert
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
@ -69,8 +76,9 @@ from src.contract import (
)
from src.core.config import Settings
from src.core.nats import NatsPublishError
from src.models.enums import RecognitionPipeline, RecognitionStatus, S3BucketRole
from src.models.enums import FileStatus, RecognitionPipeline, RecognitionStatus, S3BucketRole
from src.models.files import RecognitionJob, S3Object, StoredFile
from src.services.file_status import set_file_status_for_job, set_file_status_for_jobs
WATCHDOG_PERIOD_SECONDS = 30 # период вахтёра сроков (spec «Таймаут ожидания результата»)
PUBLISH_BATCH_SIZE = 50 # заданий за один проход публикатора (design D-6, LIMIT 50)
@ -220,6 +228,7 @@ async def _process_one( # noqa: PLR0913 - один шаг прохода: вс
finished_at=now,
)
)
await set_file_status_for_job(session, row, FileStatus.ERROR, now=now)
report.dlq += 1
logger.warning(
"Запрос распознавания без ack: задание переведено в dlq. error_category=no_ack, job_id={job_id}, "
@ -348,8 +357,9 @@ async def publish_pending( # noqa: PLR0913 - параметры прохода
async def expire_deadlines(session_factory: async_sessionmaker[AsyncSession], *, now: Clock | None = None) -> int:
"""Вахтёр сроков (spec «Таймаут ожидания результата»): `processing`/`published` с `reply_deadline < now` →
`timeout`, `error_code = TIMEOUT`, `finished_at = now`. Один атомарный `UPDATE` с условием по статусу: результат,
успевший перевести задание в финальный статус, не перетирается. Возвращает число заданий."""
`timeout`, `error_code = TIMEOUT`, `finished_at = now`. Один атомарный `UPDATE … RETURNING id` с условием по
статусу: результат, успевший перевести задание в финальный статус, не перетирается. В той же транзакции файлы этих
заданий (только где задание — текущее, `current_recognition_job_id`) → `ERROR`. Возвращает число заданий."""
moment = (now or _utcnow)()
stmt = (
update(RecognitionJob)
@ -363,13 +373,19 @@ async def expire_deadlines(session_factory: async_sessionmaker[AsyncSession], *,
error_message="Сервис распознавания не прислал результат до срока ответа (reply_deadline)",
finished_at=moment,
)
.returning(RecognitionJob.id)
.execution_options(synchronize_session=False)
)
async with session_factory() as session, session.begin():
result = await session.execute(stmt)
count = result.rowcount if isinstance(result, CursorResult) else 0
job_ids = list((await session.execute(stmt)).scalars())
files = await set_file_status_for_jobs(session, job_ids, FileStatus.ERROR, now=moment)
count = len(job_ids)
if count:
logger.warning("Задания распознавания переведены в timeout по сроку ответа. count={count}", count=count)
logger.warning(
"Задания распознавания переведены в timeout по сроку ответа. count={count}, files_error={files}",
count=count,
files=files,
)
return count
@ -495,31 +511,6 @@ class InboundMessage(Protocol):
def subject(self) -> str: ...
class ResultMapper(Protocol):
"""Точка расширения inbox (design D-6): маппинг принятого результата в доменные таблицы.
Вызывается в транзакции обработки сообщения, после обновления задания (`job` уже содержит статус и ссылку на
файл результата и может быть уточнено mapper'ом). Исключение откатывает всю транзакцию: задание не меняется и
дойдёт до `timeout`. Сетевой IO внутри — на совести реализации: транзакция держит блокировку строки задания.
Срез 0 — `NoopMapper`; Срез 2 заменит чтением файла результата и записью `document` / `osv_entry` / …
"""
async def map_result(
self, session: AsyncSession, job: RecognitionJob, message: RecognitionResultMessage
) -> None: ...
class NoopMapper:
"""Mapper Среза 0: результат только регистрируется (`s3_object`, задание), доменные таблицы не заполняются."""
async def map_result(self, session: AsyncSession, job: RecognitionJob, message: RecognitionResultMessage) -> None:
logger.debug("Маппинг результата не выполняется (NoopMapper, Срез 0). job_id={job_id}", job_id=message.job_id)
class _MapperError(Exception):
"""Исключение `ResultMapper.map_result` — отличаем от ошибок БД в логе."""
def header_msg_id(headers: Mapping[str, str] | None) -> str | None:
"""Значение заголовка `Nats-Msg-Id` без учёта регистра имени; нет заголовка или пустое значение — `None`."""
if not headers:
@ -588,19 +579,26 @@ async def _lock_job(session: AsyncSession, job_id: uuid.UUID) -> RecognitionJob
return (await session.execute(stmt)).scalar_one_or_none()
def _apply_ack(job: RecognitionJob, message: RecognitionResultMessage, msg_id: str, now: datetime) -> None:
"""Ack (`status = processing`): `published` → `processing`; повтор для `processing` — тихо; иначе — warning."""
async def _apply_ack(
session: AsyncSession, job: RecognitionJob, message: RecognitionResultMessage, msg_id: str, now: datetime
) -> None:
"""Ack (`status = processing`): `published` → `processing` и файл `UPLOADED → PROCESSING` (R-5); повтор для
`processing` — тихо; иначе — warning (запоздавший ack после `timeout`/`dlq` файл не трогает)."""
if job.status is RecognitionStatus.published:
job.status = RecognitionStatus.processing
job.started_at = now
job.service_name = message.service.name.value
job.service_version = message.service.version
file_changed = await set_file_status_for_job(
session, job, FileStatus.PROCESSING, now=now, only_from={FileStatus.UPLOADED}
)
logger.info(
"Сервис подтвердил получение запроса (ack): задание в processing. job_id={job_id}, service={service}, "
"msg_id={msg_id}",
"msg_id={msg_id}, file_processing={file_changed}",
job_id=job.id,
service=message.service.name.value,
msg_id=msg_id,
file_changed=file_changed,
)
elif job.status is RecognitionStatus.processing:
logger.debug("Повторный ack для задания в processing, игнорируется. job_id={job_id}", job_id=job.id)
@ -653,7 +651,7 @@ async def _apply_result(
session: AsyncSession, job: RecognitionJob, message: RecognitionResultMessage, msg_id: str, now: datetime
) -> bool:
"""Результат (`done | needs_check | error`): дубликат и финальный статус — без изменений (`False`); иначе —
регистрация файла результата и обновление задания (`True`, дальше — mapper и commit)."""
регистрация файла результата, обновление задания и статуса файла (`True`, дальше — commit)."""
if job.result_msg_id == msg_id:
logger.debug(
"Повторная доставка результата (дубликат по result_msg_id), изменений нет. job_id={job_id}, "
@ -689,26 +687,26 @@ async def _apply_result(
job.result_schema_version = message.result_schema_version
job.result_s3_object_id = result_s3_object_id
job.result_msg_id = msg_id
return True
async def _map_result(
session: AsyncSession, job: RecognitionJob, message: RecognitionResultMessage, mapper: ResultMapper
) -> None:
"""`mapper.map_result` в транзакции сообщения; его исключение — `_MapperError` (откат всей транзакции)."""
try:
await mapper.map_result(session, job, message)
except Exception as exc:
raise _MapperError(f"ResultMapper.map_result: {type(exc).__name__}") from exc
file_changed = await _apply_result_file_status(session, job, now)
logger.info(
"Результат распознавания принят. job_id={job_id}, status={status}, service={service}, msg_id={msg_id}, "
"error_code={error_code}",
"error_code={error_code}, file_changed={file_changed}",
job_id=job.id,
status=job.status.value,
service=message.service.name.value,
msg_id=job.result_msg_id,
msg_id=msg_id,
error_code=job.error_code,
file_changed=file_changed,
)
return True
async def _apply_result_file_status(session: AsyncSession, job: RecognitionJob, now: datetime) -> bool:
"""Статус файла по принятому результату (design D-5): `error` → `ERROR`; `done`/`needs_check` → `PROCESSING` только
из `UPLOADED` (R-4: ack мог потеряться), иначе файл ждёт цикла маппинга как есть. Только текущее задание файла."""
if job.status is RecognitionStatus.error:
return await set_file_status_for_job(session, job, FileStatus.ERROR, now=now)
return await set_file_status_for_job(session, job, FileStatus.PROCESSING, now=now, only_from={FileStatus.UPLOADED})
def _metrics_json(metrics: Metrics) -> dict[str, Any]:
@ -716,7 +714,7 @@ def _metrics_json(metrics: Metrics) -> dict[str, Any]:
async def _apply_message(
session: AsyncSession, message: RecognitionResultMessage, header_id: str | None, mapper: ResultMapper, clock: Clock
session: AsyncSession, message: RecognitionResultMessage, header_id: str | None, clock: Clock
) -> None:
job = await _lock_job(session, message.job_id)
if job is None:
@ -748,36 +746,30 @@ async def _apply_message(
)
now = clock()
if is_ack:
_apply_ack(job, message, msg_id, now)
elif await _apply_result(session, job, message, msg_id, now):
await _map_result(session, job, message, mapper)
await _apply_ack(session, job, message, msg_id, now)
else:
await _apply_result(session, job, message, msg_id, now)
async def handle_message(
msg: InboundMessage,
*,
session_factory: async_sessionmaker[AsyncSession],
mapper: ResultMapper | None = None,
now: Clock | None = None,
) -> None:
"""Обработчик `recognition.result` (spec «Ack сервиса переводит задание в `processing`», «Inbox принимает
результат идемпотентно»; design D-6). Модель транзакции и правила — в docstring модуля.
Исключения наружу не выходят: невалидное сообщение — warning; ошибка БД или mapper'а — откат транзакции,
`logger.exception`, без повторов (задание дойдёт до `timeout`). `audit_event` не пишется (R-7).
Исключения наружу не выходят: невалидное сообщение — warning; ошибка БД — откат транзакции (задание и файл),
`logger.exception`, без повторов (задание дойдёт до `timeout`). `audit_event` не пишется (R-7,
ASSUMPTION(OQ-55)). Файл результата не читается: маппинг — отдельный цикл (design D-1).
"""
message = _parse_message(msg)
if message is None:
return
try:
async with session_factory() as session, session.begin():
await _apply_message(session, message, header_msg_id(msg.headers), mapper or NoopMapper(), now or _utcnow)
except _MapperError:
logger.exception(
"Ошибка маппинга результата распознавания, транзакция откачена; задание дойдёт до timeout. "
"error_category=mapper_error, job_id={job_id}",
job_id=message.job_id,
)
await _apply_message(session, message, header_msg_id(msg.headers), now or _utcnow)
except Exception as exc:
logger.exception(
"Ошибка обработки сообщения {subject}, транзакция откачена (без повторов). error_category=inbox_error, "

View File

@ -8,7 +8,8 @@
`FakeStorage` для тестов загрузки и маппинга (Срез 2c; `flush`, без `commit`). `make_entries_from_result` создаёт
Task 9 (R-2).
- `CommittedJobs` — задания распознавания, ЗАФИКСИРОВАННЫЕ в БД (`commit`): для фоновых процессов обмена, которые
работают в собственных транзакциях и не видят чужую незафиксированную транзакцию. Всё созданное удаляется
работают в собственных транзакциях и не видят чужую незафиксированную транзакцию. `make_file_job` / `add_job` — файл
с текущим заданием и второе задание того же файла (статусы `stored_file` в обмене, Срез 2c). Всё созданное удаляется
`cleanup()` в порядке FK, включая строки `s3_object` файлов результата, зарегистрированные inbox'ом.
"""
@ -400,6 +401,71 @@ class CommittedJobs:
self.job_ids.append(job.id)
return job.id
async def make_file_job(
self,
*,
file_status: FileStatus = FileStatus.UPLOADED,
job_status: RecognitionStatus = RecognitionStatus.queued,
**job_fields: Any,
) -> tuple[uuid.UUID, uuid.UUID]:
"""Файл с текущим заданием (`make_stored_file`: `current_recognition_job_id` = задание), зафиксированный в БД.
`job_fields` — поля `recognition_job` поверх фабрики (`reply_deadline`, `published_at`, `metrics`, …).
Возвращает `(stored_file.id, recognition_job.id)`.
"""
async with self._session_factory() as session, session.begin():
entity = await make_legal_entity(session, name="ООО Тест статусов файла")
file, job = await make_stored_file(session, entity=entity, status=file_status, job_status=job_status)
for name, value in job_fields.items():
setattr(job, name, value)
await session.flush()
self._entity_ids.append(entity.id)
self._s3_ids.append(file.s3_object_id)
self._file_ids.append(file.id)
self.job_ids.append(job.id)
return file.id, job.id
async def add_job(
self,
file_id: uuid.UUID,
*,
status: RecognitionStatus = RecognitionStatus.queued,
current: bool = True,
**job_fields: Any,
) -> uuid.UUID:
"""Ещё одно задание файла `file_id` (как replay: `attempt` = максимум по файлу + 1); `current=True` — оно
становится текущим заданием файла."""
async with self._session_factory() as session, session.begin():
file = await session.get(StoredFile, file_id)
assert file is not None
last = (
await session.execute(select(func.max(RecognitionJob.attempt)).where(RecognitionJob.file_id == file_id))
).scalar_one()
job_fields.setdefault("attempt", (last or 0) + 1)
tabular = file.file_type in TABULAR_FILE_TYPES
pipeline = RecognitionPipeline.tabular if tabular else RecognitionPipeline.image
job = RecognitionJob(
file_id=file_id,
pipeline=pipeline,
request_subject=f"recognition.{pipeline.value}.request",
schema_version="1.0",
status=status,
**job_fields,
)
session.add(job)
await session.flush()
if current:
file.current_recognition_job_id = job.id
self.job_ids.append(job.id)
return job.id
async def file(self, file_id: uuid.UUID) -> StoredFile:
"""Свежее состояние файла из БД (новая сессия)."""
async with self._session_factory() as session:
file = await session.get(StoredFile, file_id)
assert file is not None
return file
async def s3_key(self, job_id: uuid.UUID) -> str:
"""Ключ S3 исходного файла задания."""
async with self._session_factory() as session:

View File

@ -226,7 +226,8 @@ def test_create_nats_enabled_returns_connection_for_results(monkeypatch: pytest.
async def test_result_subscription_uses_inbox_handler_with_dependencies():
"""Подписка `recognition.result` получает `handle_message` с фабрикой сессий приложения и `NoopMapper`."""
"""Подписка `recognition.result` получает `handle_message` с фабрикой сессий приложения (без `mapper`: маппинг
результата — отдельный цикл, Срез 2c D-1)."""
subscribed: list[Any] = []
class _FakeConnection:
@ -238,9 +239,8 @@ async def test_result_subscription_uses_inbox_handler_with_dependencies():
(handler,) = subscribed
assert handler.func is recognition_exchange.handle_message
assert handler.args == ()
assert set(handler.keywords) == {"session_factory", "mapper"}
assert set(handler.keywords) == {"session_factory"}
assert handler.keywords["session_factory"] is app_module.async_session_maker
assert isinstance(handler.keywords["mapper"], recognition_exchange.NoopMapper)
def test_create_nats_subscribes_results_on_connect(monkeypatch: pytest.MonkeyPatch):

View File

@ -32,7 +32,7 @@ from src.core.config import get_settings
from src.core.nats import RESULT_QUEUE_GROUP, NatsConnection
from src.models.enums import RecognitionStatus, S3BucketRole
from src.models.files import RecognitionJob, S3Object
from src.services.recognition_exchange import NoopMapper, handle_message
from src.services.recognition_exchange import handle_message
from tests.factories import CommittedJobs
from tests.test_recognition_inbox import MESSAGE_VALIDATOR, envelope
@ -277,10 +277,7 @@ async def test_two_core_instances_handle_result_once(
"""Два `NatsConnection` в queue group `arm-core` на одном subject: результат обрабатывает ровно один инстанс,
задание меняется один раз; повторная доставка того же сообщения ничего не меняет."""
settings = get_settings().OPENBAO.NATS
handlers = [
_CountingHandler(partial(handle_message, session_factory=session_factory, mapper=NoopMapper()))
for _ in range(2)
]
handlers = [_CountingHandler(partial(handle_message, session_factory=session_factory)) for _ in range(2)]
def subscriber(handler: _CountingHandler) -> Callable[[NatsConnection], Awaitable[None]]:
async def subscribe(conn: NatsConnection) -> None:

View File

@ -1,5 +1,7 @@
"""Outbox-публикатор и вахтёр сроков `recognition_job` (spec recognition-job-exchange: «Outbox публикует задания из
`queued`», «Запрос без ack публикуется повторно, затем — `dlq`», «Таймаут ожидания результата»; design D-6).
`queued`», «Запрос без ack публикуется повторно, затем — `dlq`», «Таймаут ожидания результата»; design D-6). Срез 2c:
`timeout` вахтёра и `dlq` публикатора переводят файл текущего задания в `ERROR` (spec primary-files «Жизненный цикл
статуса файла», design D-5).
Интеграционные тесты работают против реального PostgreSQL (БД сессии pytest) и NATS из `.env.test`. Публикатор и
вахтёр ведут собственные транзакции, поэтому данные фиксируются реально (`CommittedJobs`) и удаляются в teardown.
@ -28,7 +30,7 @@ from src.contract import NATS_MSG_ID_HEADER, RecognitionRequest
from src.contract.request import FileType as ContractFileType
from src.core.config import get_settings
from src.core.nats import NatsConnection, NatsPublishError
from src.models.enums import FileType, RecognitionPipeline, RecognitionStatus
from src.models.enums import FileStatus, FileType, RecognitionPipeline, RecognitionStatus
from src.models.files import RecognitionJob
from src.services import recognition_exchange as rx
from src.services.recognition_exchange import (
@ -591,6 +593,93 @@ async def test_expire_deadlines(
assert job.finished_at is None
@pytest.mark.integration
@pytest.mark.parametrize("file_status", [FileStatus.PROCESSING, FileStatus.UPLOADED])
@pytest.mark.parametrize("job_status", [RecognitionStatus.processing, RecognitionStatus.published])
async def test_expire_deadlines_moves_current_file_to_error(
session_factory: async_sessionmaker[AsyncSession],
jobs: CommittedJobs,
file_status: FileStatus,
job_status: RecognitionStatus,
):
"""Сценарий «Сервис не ответил» (primary-files): задание — `timeout`, файл — `ERROR` в той же транзакции."""
clock = Clock()
file_id, job_id = await jobs.make_file_job(
file_status=file_status, job_status=job_status, reply_deadline=clock() - timedelta(minutes=1)
)
assert await expire_deadlines(session_factory, now=clock) == 1
job, file = await jobs.get(job_id), await jobs.file(file_id)
assert (job.status, job.error_code) == (RecognitionStatus.timeout, "TIMEOUT")
assert (file.status, file.updated_at) == (FileStatus.ERROR, clock())
@pytest.mark.integration
@pytest.mark.parametrize("file_status", [FileStatus.PROCESSED, FileStatus.SUPERSEDED])
async def test_expire_deadlines_keeps_processed_and_superseded_file(
session_factory: async_sessionmaker[AsyncSession], jobs: CommittedJobs, file_status: FileStatus
):
clock = Clock()
file_id, job_id = await jobs.make_file_job(
file_status=file_status, job_status=RecognitionStatus.processing, reply_deadline=clock() - timedelta(minutes=1)
)
assert await expire_deadlines(session_factory, now=clock) == 1
assert (await jobs.get(job_id)).status is RecognitionStatus.timeout
file = await jobs.file(file_id)
assert (file.status, file.updated_at) == (file_status, None)
@pytest.mark.integration
async def test_expire_deadlines_of_non_current_job_keeps_file(
session_factory: async_sessionmaker[AsyncSession], jobs: CommittedJobs
):
"""Review Focus 1: таймаут старого задания файла, у которого уже новое текущее задание, файл не трогает."""
clock = Clock()
file_id, old_job = await jobs.make_file_job(
file_status=FileStatus.UPLOADED,
job_status=RecognitionStatus.processing,
reply_deadline=clock() - timedelta(minutes=1),
)
new_job = await jobs.add_job(file_id, status=RecognitionStatus.queued, current=True)
assert await expire_deadlines(session_factory, now=clock) == 1
assert (await jobs.get(old_job)).status is RecognitionStatus.timeout
file = await jobs.file(file_id)
assert (file.status, file.current_recognition_job_id, file.updated_at) == (FileStatus.UPLOADED, new_job, None)
@pytest.mark.integration
@pytest.mark.parametrize("current", [True, False], ids=["current", "non_current"])
async def test_dlq_moves_current_file_to_error(
session_factory: async_sessionmaker[AsyncSession], jobs: CommittedJobs, current: bool
):
"""Публикатор переводит задание в `dlq` — файл текущего задания `ERROR`; файл чужого задания не меняется."""
clock = Clock()
file_id, job_id = await jobs.make_file_job(
job_status=RecognitionStatus.published,
published_at=clock() - ACK * 2,
request_msg_id="dlq-test:1",
reply_deadline=clock() + timedelta(hours=1),
metrics={"republish_count": MAX_REPUBLISH},
)
# новое задание — `processing`, чтобы публикатор его не трогал (в проходе только задание старого)
new_job = None if current else await jobs.add_job(file_id, status=RecognitionStatus.processing, current=True)
report = await _publish(session_factory, FakeNats(), clock)
assert report.dlq == 1
assert (await jobs.get(job_id)).status is RecognitionStatus.dlq
file = await jobs.file(file_id)
if current:
assert (file.status, file.updated_at) == (FileStatus.ERROR, clock())
else:
assert (file.status, file.current_recognition_job_id, file.updated_at) == (FileStatus.UPLOADED, new_job, None)
# --- фоновые циклы -----------------------------------------------------------------------------------------------

View File

@ -1,5 +1,7 @@
"""Inbox обмена `recognition_job`: `handle_message()` (spec recognition-job-exchange: «Ack сервиса переводит задание в
`processing`», «Inbox принимает результат идемпотентно»; design D-6; рулинги R-7, R-12, R-22).
`processing`», «Inbox принимает результат идемпотентно»; design D-6; рулинги R-7, R-12, R-22). Срез 2c: статусы
`stored_file` по ack / результату (spec primary-files «Жизненный цикл статуса файла», design D-5, рулинги R-4, R-5);
`ResultMapper` удалён — inbox не читает S3 и не создаёт документы (маппинг — отдельный цикл, D-1).
Быстрые тесты (без маркера) — разбор заголовка `Nats-Msg-Id`, слияние метрик, отказ невалидным сообщениям без
обращения к БД, ошибка БД не выходит наружу, шаблонный `nats_callback` удалён.
@ -10,6 +12,7 @@
import asyncio
import importlib.util
import inspect
import json
import time
import uuid
@ -29,14 +32,14 @@ from nats.aio.msg import Msg
from sqlalchemy import JSON, func, select
from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession, async_sessionmaker, create_async_engine
from src.contract import NATS_MSG_ID_HEADER, RecognitionResultMessage, ack_msg_id, result_msg_id
from src.contract import NATS_MSG_ID_HEADER, ack_msg_id, result_msg_id
from src.core.config import get_settings
from src.core.nats import NatsConnection
from src.models.enums import RecognitionStatus, S3BucketRole
from src.models.files import RecognitionJob, S3Object
from src.models.enums import FileStatus, RecognitionStatus, S3BucketRole
from src.models.files import RecognitionJob, S3Object, StoredFile
from src.services import recognition_exchange as rx
from src.services.recognition_exchange import (
FINAL_STATUSES,
NoopMapper,
expire_deadlines,
handle_message,
header_msg_id,
@ -54,6 +57,7 @@ BUCKET = "arm-ka"
READY_TIMEOUT = 5.0
RACE_JOBS = 10
LOCK_HOLD_SECONDS = 0.5
_ERROR = {"code": "UNSUPPORTED_FORMAT", "message": "формат файла не поддерживается"}
# --- инфраструктура ---------------------------------------------------------------------------------------------
@ -103,21 +107,20 @@ def fake_msg(body: Any, msg_id: str | None, *, header: str = NATS_MSG_ID_HEADER,
return SimpleNamespace(data=data, headers=headers, subject="recognition.result")
class RecordingMapper:
"""Подставной mapper: запоминает вызовы и видимое состояние задания; `fail=True` — исключение; `delay` — пауза
внутри транзакции (строка задания остаётся заблокированной)."""
class SlowFileStatus:
"""Подмена `set_file_status_for_job` в inbox: запоминает вызовы (id задания, статус) и держит паузу `delay`
ВНУТРИ транзакции сообщения (строка задания остаётся заблокированной), затем вызывает настоящую функцию."""
def __init__(self, *, fail: bool = False, delay: float = 0) -> None:
self.fail = fail
def __init__(self, monkeypatch: pytest.MonkeyPatch, *, delay: float) -> None:
self.delay = delay
self.calls: list[tuple[uuid.UUID, RecognitionStatus, RecognitionResultMessage]] = []
self.calls: list[tuple[uuid.UUID, FileStatus]] = []
self._original = rx.set_file_status_for_job
monkeypatch.setattr(rx, "set_file_status_for_job", self)
async def map_result(self, session: AsyncSession, job: RecognitionJob, message: RecognitionResultMessage) -> None:
self.calls.append((job.id, job.status, message))
if self.delay:
await asyncio.sleep(self.delay)
if self.fail:
raise RuntimeError("подставная ошибка маппинга")
async def __call__(self, session: AsyncSession, job: RecognitionJob, status: FileStatus, **kwargs: Any) -> bool:
self.calls.append((job.id, status))
await asyncio.sleep(self.delay)
return await self._original(session, job, status, **kwargs)
@pytest.fixture
@ -152,6 +155,13 @@ async def _s3_total(session_factory: async_sessionmaker[AsyncSession]) -> int:
return int((await session.execute(select(func.count()).select_from(S3Object))).scalar_one())
async def _file_row(session_factory: async_sessionmaker[AsyncSession], file_id: uuid.UUID) -> dict[str, Any]:
"""Снимок всех колонок строки `stored_file`."""
async with session_factory() as session:
row = (await session.execute(select(StoredFile.__table__).where(StoredFile.id == file_id))).one()
return dict(row._mapping)
async def _file_id(jobs: CommittedJobs, job_id: uuid.UUID) -> uuid.UUID:
return (await jobs.get(job_id)).file_id
@ -264,9 +274,11 @@ async def test_database_error_is_logged_and_not_raised(logs: list[Any]):
assert record["exception"] is not None
async def test_noop_mapper_does_nothing():
message = RecognitionResultMessage.model_validate(envelope(_JOB, _FILE, "done"))
assert await NoopMapper().map_result(None, None, message) is None # type: ignore[arg-type]
def test_inbox_has_no_result_mapper():
"""OQ-67 (Срез 2c, D-1): точки расширения `ResultMapper` / `NoopMapper` нет, `handle_message` без `mapper`."""
assert not hasattr(rx, "ResultMapper")
assert not hasattr(rx, "NoopMapper")
assert "mapper" not in inspect.signature(handle_message).parameters
def test_final_statuses():
@ -422,22 +434,23 @@ async def test_result_metrics_merge_over_null_metrics(
@pytest.mark.integration
async def test_redelivery_changes_nothing(session_factory, jobs: CommittedJobs, logs: list[Any]):
"""Сценарий «Повторная доставка»: ни recognition_job, ни s3_object не меняются; mapper не вызывается."""
job_id = await jobs.make_queued_job(status=RecognitionStatus.processing)
file_id = await _file_id(jobs, job_id)
"""Сценарий «Повторная доставка»: ни recognition_job, ни s3_object, ни stored_file не меняются."""
file_id, job_id = await jobs.make_file_job(
file_status=FileStatus.PROCESSING, job_status=RecognitionStatus.processing
)
msg = fake_msg(envelope(job_id, file_id, "done", metrics={"rows": 3}), result_msg_id(job_id, 1))
mapper = RecordingMapper()
await _handle(session_factory, msg, mapper=mapper)
await _handle(session_factory, msg)
job_before = await _row(session_factory, job_id)
file_before = await _file_row(session_factory, file_id)
s3_before = await _s3_rows(session_factory, _result_key(file_id, job_id))
total_before = await _s3_total(session_factory)
await _handle(session_factory, msg, mapper=mapper, now=lambda: datetime.now(UTC) + timedelta(hours=1))
await _handle(session_factory, msg, now=lambda: datetime.now(UTC) + timedelta(hours=1))
assert await _row(session_factory, job_id) == job_before
assert await _s3_rows(session_factory, _result_key(file_id, job_id)) == s3_before
assert await _s3_total(session_factory) == total_before
assert len(mapper.calls) == 1
assert await _file_row(session_factory, file_id) == file_before
assert _logged(logs, "дубликат", "DEBUG")
assert not _logged(logs, "финальном статусе")
@ -482,13 +495,11 @@ async def test_result_for_final_status_is_logged_and_ignored(
file_id = await _file_id(jobs, job_id)
before = await _row(session_factory, job_id)
total_before = await _s3_total(session_factory)
mapper = RecordingMapper()
await _handle(session_factory, fake_msg(envelope(job_id, file_id, "done"), result_msg_id(job_id, 1)), mapper=mapper)
await _handle(session_factory, fake_msg(envelope(job_id, file_id, "done"), result_msg_id(job_id, 1)))
assert await _row(session_factory, job_id) == before
assert await _s3_total(session_factory) == total_before
assert mapper.calls == []
(record,) = _logged(logs, "финальном статусе", "WARNING")
assert status.value in record["message"]
@ -587,39 +598,163 @@ async def test_existing_s3_object_is_reused(session_factory, jobs: CommittedJobs
assert len(await _s3_rows(session_factory, _result_key(file_id, job_id))) == 1
# --- mapper ------------------------------------------------------------------------------------------------------
# --- статусы stored_file (Срез 2c: spec primary-files «Жизненный цикл статуса файла», D-5, R-4, R-5) -------------
@pytest.mark.integration
async def test_mapper_sees_job_and_message(session_factory, jobs: CommittedJobs):
job_id = await jobs.make_queued_job(status=RecognitionStatus.processing)
file_id = await _file_id(jobs, job_id)
mapper = RecordingMapper()
async def test_ack_moves_uploaded_file_to_processing(session_factory, jobs: CommittedJobs):
"""Сценарий «Сервис подтвердил»: файл текущего задания — `PROCESSING`, `updated_at` — момент ack."""
file_id, job_id = await jobs.make_file_job(job_status=RecognitionStatus.published)
moment = datetime(2026, 10, 5, 9, 0, tzinfo=UTC)
await _handle(session_factory, fake_msg(envelope(job_id, file_id, "done"), result_msg_id(job_id, 1)), mapper=mapper)
await _handle(
session_factory, fake_msg(envelope(job_id, file_id, "processing"), ack_msg_id(job_id, 1)), now=lambda: moment
)
((seen_job_id, seen_status, message),) = mapper.calls
assert seen_job_id == job_id
assert seen_status is RecognitionStatus.done # mapper видит уже обновлённое задание (может его уточнить)
assert message.job_id == job_id
assert message.result_s3 is not None
assert message.result_s3.key == _result_key(file_id, job_id)
file = await _file_row(session_factory, file_id)
assert file["status"] is FileStatus.PROCESSING
assert file["updated_at"] == moment
assert file["current_recognition_job_id"] == job_id
assert (await _row(session_factory, job_id))["status"] is RecognitionStatus.processing
@pytest.mark.integration
async def test_mapper_error_rolls_back_whole_transaction(session_factory, jobs: CommittedJobs, logs: list[Any]):
"""Исключение mapper'а — откат всего: задание не изменено, s3_object не создан (дойдёт до timeout, D-6)."""
job_id = await jobs.make_queued_job(status=RecognitionStatus.processing)
file_id = await _file_id(jobs, job_id)
before = await _row(session_factory, job_id)
total_before = await _s3_total(session_factory)
@pytest.mark.parametrize(
"file_status", [FileStatus.ERROR, FileStatus.PROCESSED, FileStatus.SUPERSEDED, FileStatus.PROCESSING]
)
async def test_ack_moves_file_only_from_uploaded(session_factory, jobs: CommittedJobs, file_status: FileStatus):
"""R-5: ack ставит `PROCESSING` только из `UPLOADED` — запоздавший ack не воскрешает `ERROR` и не трогает
`PROCESSED`/`SUPERSEDED`; задание при этом подтверждается как обычно."""
file_id, job_id = await jobs.make_file_job(file_status=file_status, job_status=RecognitionStatus.published)
before = await _file_row(session_factory, file_id)
msg = fake_msg(envelope(job_id, file_id, "done"), result_msg_id(job_id, 1))
await _handle(session_factory, msg, mapper=RecordingMapper(fail=True))
await _handle(session_factory, fake_msg(envelope(job_id, file_id, "processing"), ack_msg_id(job_id, 1)))
assert await _row(session_factory, job_id) == before
assert await _s3_total(session_factory) == total_before
(record,) = _logged(logs, "Ошибка маппинга", "ERROR")
assert await _file_row(session_factory, file_id) == before
assert (await _row(session_factory, job_id))["status"] is RecognitionStatus.processing
@pytest.mark.integration
@pytest.mark.parametrize("file_status", [FileStatus.PROCESSING, FileStatus.UPLOADED])
async def test_error_result_moves_file_to_error(session_factory, jobs: CommittedJobs, file_status: FileStatus):
"""Сценарий «Парсер вернул ошибку»: файл — `ERROR`, причина — в задании (`error_code`)."""
file_id, job_id = await jobs.make_file_job(file_status=file_status, job_status=RecognitionStatus.processing)
moment = datetime(2026, 10, 5, 9, 30, tzinfo=UTC)
await _handle(
session_factory,
fake_msg(envelope(job_id, file_id, "error", error=_ERROR), result_msg_id(job_id, 1)),
now=lambda: moment,
)
file = await _file_row(session_factory, file_id)
assert (file["status"], file["updated_at"]) == (FileStatus.ERROR, moment)
assert (await _row(session_factory, job_id))["error_code"] == "UNSUPPORTED_FORMAT"
@pytest.mark.integration
@pytest.mark.parametrize("status", ["done", "needs_check"])
async def test_success_result_leaves_processing_file_for_mapping(session_factory, jobs: CommittedJobs, status: str):
"""Сценарий «Успешный результат»: файл остаётся `PROCESSING` до маппинга; строка файла не меняется вовсе."""
file_id, job_id = await jobs.make_file_job(
file_status=FileStatus.PROCESSING, job_status=RecognitionStatus.processing
)
before = await _file_row(session_factory, file_id)
await _handle(session_factory, fake_msg(envelope(job_id, file_id, status), result_msg_id(job_id, 1)))
assert await _file_row(session_factory, file_id) == before
assert (await _row(session_factory, job_id))["status"] is RecognitionStatus(status)
@pytest.mark.integration
@pytest.mark.parametrize("status", ["done", "needs_check"])
@pytest.mark.parametrize("job_status", [RecognitionStatus.published, RecognitionStatus.queued])
async def test_success_result_without_ack_moves_uploaded_file_to_processing(
session_factory, jobs: CommittedJobs, status: str, job_status: RecognitionStatus
):
"""R-4: ack потерялся (core NATS без JetStream) — результат `done`/`needs_check` переводит файл `UPLOADED →
PROCESSING`, иначе файл с готовым результатом не попал бы в цикл маппинга."""
file_id, job_id = await jobs.make_file_job(job_status=job_status)
moment = datetime(2026, 10, 5, 10, 0, tzinfo=UTC)
await _handle(
session_factory, fake_msg(envelope(job_id, file_id, status), result_msg_id(job_id, 1)), now=lambda: moment
)
file = await _file_row(session_factory, file_id)
assert (file["status"], file["updated_at"]) == (FileStatus.PROCESSING, moment)
assert (await _row(session_factory, job_id))["status"] is RecognitionStatus(status)
@pytest.mark.integration
@pytest.mark.parametrize("file_status", [FileStatus.PROCESSED, FileStatus.SUPERSEDED])
@pytest.mark.parametrize("status", ["done", "error"])
async def test_result_does_not_touch_processed_or_superseded_file(
session_factory, jobs: CommittedJobs, file_status: FileStatus, status: str
):
"""`PROCESSED` и `SUPERSEDED` — итог маппинга / автозамены: результат обмена их не перезаписывает."""
file_id, job_id = await jobs.make_file_job(file_status=file_status, job_status=RecognitionStatus.processing)
before = await _file_row(session_factory, file_id)
extra = {"error": _ERROR} if status == "error" else {}
await _handle(session_factory, fake_msg(envelope(job_id, file_id, status, **extra), result_msg_id(job_id, 1)))
assert await _file_row(session_factory, file_id) == before
assert (await _row(session_factory, job_id))["status"] is RecognitionStatus(status)
@pytest.mark.integration
@pytest.mark.parametrize(
("status", "msg_id_fn", "expected_job"),
[
("processing", ack_msg_id, RecognitionStatus.processing),
("done", result_msg_id, RecognitionStatus.done),
("needs_check", result_msg_id, RecognitionStatus.needs_check),
("error", result_msg_id, RecognitionStatus.error),
],
)
async def test_message_of_non_current_job_does_not_touch_file( # noqa: PLR0913 - фикстуры и параметры
session_factory, jobs: CommittedJobs, status: str, msg_id_fn: Any, expected_job: RecognitionStatus
):
"""Review Focus 1: у файла два задания, текущее — новое; ack/результат старого задания применяется к старому
заданию, но файл (статус, `updated_at`, `current_recognition_job_id`) не трогает."""
file_id, old_job = await jobs.make_file_job(job_status=RecognitionStatus.published)
new_job = await jobs.add_job(file_id, status=RecognitionStatus.queued, current=True)
before = await _file_row(session_factory, file_id)
assert before["current_recognition_job_id"] == new_job
extra = {"error": _ERROR} if status == "error" else {}
await _handle(session_factory, fake_msg(envelope(old_job, file_id, status, **extra), msg_id_fn(old_job, 1)))
assert (await _row(session_factory, old_job))["status"] is expected_job
assert await _file_row(session_factory, file_id) == before
assert (await _row(session_factory, new_job))["status"] is RecognitionStatus.queued
@pytest.mark.integration
async def test_file_status_rolls_back_with_message_transaction(
session_factory, jobs: CommittedJobs, logs: list[Any], monkeypatch: pytest.MonkeyPatch
):
"""Статус файла меняется в транзакции сообщения: ошибка после обновления задания откатывает и задание, и файл."""
file_id, job_id = await jobs.make_file_job(
file_status=FileStatus.PROCESSING, job_status=RecognitionStatus.processing
)
job_before = await _row(session_factory, job_id)
file_before = await _file_row(session_factory, file_id)
original = rx.set_file_status_for_job
async def failing(session: AsyncSession, job: RecognitionJob, status: FileStatus, **kwargs: Any) -> bool:
await original(session, job, status, **kwargs)
raise RuntimeError("подставная ошибка после обновления файла")
monkeypatch.setattr(rx, "set_file_status_for_job", failing)
await _handle(session_factory, fake_msg(envelope(job_id, file_id, "error", error=_ERROR), result_msg_id(job_id, 1)))
assert await _row(session_factory, job_id) == job_before
assert await _file_row(session_factory, file_id) == file_before
(record,) = _logged(logs, "транзакция откачена", "ERROR")
assert record["exception"] is not None
@ -627,17 +762,19 @@ async def test_mapper_error_rolls_back_whole_transaction(session_factory, jobs:
@pytest.mark.integration
async def test_concurrent_same_message_applied_once(session_factory, jobs: CommittedJobs, logs: list[Any]):
job_id = await jobs.make_queued_job(status=RecognitionStatus.processing)
file_id = await _file_id(jobs, job_id)
async def test_concurrent_same_message_applied_once(
session_factory, jobs: CommittedJobs, logs: list[Any], monkeypatch: pytest.MonkeyPatch
):
file_id, job_id = await jobs.make_file_job(job_status=RecognitionStatus.processing)
msg = fake_msg(envelope(job_id, file_id, "done"), result_msg_id(job_id, 1))
mapper = RecordingMapper(delay=LOCK_HOLD_SECONDS) # первый держит строку, второй ждёт его commit
slow = SlowFileStatus(monkeypatch, delay=LOCK_HOLD_SECONDS) # первый держит строку, второй ждёт его commit
await asyncio.gather(*[_handle(session_factory, msg, mapper=mapper) for _ in range(2)])
await asyncio.gather(*[_handle(session_factory, msg) for _ in range(2)])
assert len(await _s3_rows(session_factory, _result_key(file_id, job_id))) == 1
assert (await _row(session_factory, job_id))["result_msg_id"] == f"result:{job_id}:1"
assert len(mapper.calls) == 1
assert slow.calls == [(job_id, FileStatus.PROCESSING)] # применён один раз
assert (await _file_row(session_factory, file_id))["status"] is FileStatus.PROCESSING
assert len(_logged(logs, "дубликат", "DEBUG")) == 1
assert not _logged(logs, "неизвестного задания")
@ -684,26 +821,32 @@ async def test_ack_waits_for_publisher_lock(session_factory, jobs: CommittedJobs
@pytest.mark.integration
async def test_result_vs_watchdog_is_consistent(session_factory, jobs: CommittedJobs):
"""Результат и вахтёр на заданиях с прошедшим сроком одновременно: каждое — либо done (с s3_object), либо
timeout (без результата), но не смесь. Порядок решает блокировка строки: кто первый, тот и финальный."""
"""Результат и вахтёр на заданиях с прошедшим сроком одновременно: каждое — либо done (с s3_object, файл
`PROCESSING`), либо timeout (без результата, файл `ERROR`), но не смесь. Порядок решает блокировка строки задания:
кто первый, тот и финальный (и задание, и файл меняются в порядке «задание → файл», без взаимоблокировок)."""
past = datetime.now(UTC) - timedelta(minutes=1)
job_ids = [
await jobs.make_queued_job(status=RecognitionStatus.processing, reply_deadline=past) for _ in range(RACE_JOBS)
]
messages = [
fake_msg(envelope(job_id, await _file_id(jobs, job_id), "done"), result_msg_id(job_id, 1)) for job_id in job_ids
pairs = [
await jobs.make_file_job(
file_status=FileStatus.PROCESSING, job_status=RecognitionStatus.processing, reply_deadline=past
)
for _ in range(RACE_JOBS)
]
job_ids = [job_id for _, job_id in pairs]
messages = [fake_msg(envelope(job_id, file_id, "done"), result_msg_id(job_id, 1)) for file_id, job_id in pairs]
await asyncio.gather(expire_deadlines(session_factory), *[_handle(session_factory, m) for m in messages])
for job_id in job_ids:
job = await _row(session_factory, job_id)
s3 = await _s3_rows(session_factory, _result_key(job["file_id"], job_id))
file = await _file_row(session_factory, job["file_id"])
if job["status"] is RecognitionStatus.done:
assert job["error_code"] is None
assert job["result_msg_id"] == f"result:{job_id}:1"
assert [r["id"] for r in s3] == [job["result_s3_object_id"]]
assert file["status"] is FileStatus.PROCESSING
else:
assert file["status"] is FileStatus.ERROR
assert job["status"] is RecognitionStatus.timeout
assert job["error_code"] == "TIMEOUT"
assert job["result_msg_id"] is None
@ -712,17 +855,21 @@ async def test_result_vs_watchdog_is_consistent(session_factory, jobs: Committed
@pytest.mark.integration
async def test_watchdog_waits_for_result_transaction(session_factory, jobs: CommittedJobs):
async def test_watchdog_waits_for_result_transaction(
session_factory, jobs: CommittedJobs, monkeypatch: pytest.MonkeyPatch
):
"""Вахтёр запущен, пока обработчик результата держит строку: его UPDATE ждёт commit, перепроверяет статус на новой
версии строки (`done`) и задание не трогает."""
версии строки (`done`) и задание не трогает; файл остаётся `PROCESSING` (не `ERROR`)."""
past = datetime.now(UTC) - timedelta(minutes=1)
job_id = await jobs.make_queued_job(status=RecognitionStatus.processing, reply_deadline=past)
mapper = RecordingMapper(delay=LOCK_HOLD_SECONDS)
msg = fake_msg(envelope(job_id, await _file_id(jobs, job_id), "done"), result_msg_id(job_id, 1))
handler = asyncio.create_task(_handle(session_factory, msg, mapper=mapper))
file_id, job_id = await jobs.make_file_job(
file_status=FileStatus.PROCESSING, job_status=RecognitionStatus.processing, reply_deadline=past
)
slow = SlowFileStatus(monkeypatch, delay=LOCK_HOLD_SECONDS)
msg = fake_msg(envelope(job_id, file_id, "done"), result_msg_id(job_id, 1))
handler = asyncio.create_task(_handle(session_factory, msg))
async def locked() -> None: # обработчик внутри транзакции, строка заблокирована
while not mapper.calls:
while not slow.calls:
await asyncio.sleep(0.01)
await asyncio.wait_for(locked(), 5)
@ -734,6 +881,7 @@ async def test_watchdog_waits_for_result_transaction(session_factory, jobs: Comm
job = await _row(session_factory, job_id)
assert job["status"] is RecognitionStatus.done
assert job["error_code"] is None
assert (await _file_row(session_factory, file_id))["status"] is FileStatus.PROCESSING
@pytest.mark.integration
@ -805,17 +953,17 @@ class _FakeService:
@pytest.mark.integration
async def test_full_cycle_over_real_nats(session_factory, jobs: CommittedJobs, logs: list[Any]):
"""queued → published (outbox) → processing (ack) → done + s3_object (результат) → повтор без изменений."""
"""queued → published (outbox) → processing (ack) → done + s3_object (результат) → повтор без изменений;
файл: UPLOADED → PROCESSING (ack) → PROCESSING (результат, ждёт цикл маппинга)."""
settings = get_settings().OPENBAO.NATS
result_subject = f"test.result.{uuid.uuid4().hex}"
handler = partial(handle_message, session_factory=session_factory, mapper=NoopMapper())
handler = partial(handle_message, session_factory=session_factory)
async def subscribe(conn: NatsConnection) -> None:
await conn.subscribe_results(handler)
core = NatsConnection(settings, on_connected=subscribe, result_subject=result_subject)
job_id = await jobs.make_queued_job()
file_id = await _file_id(jobs, job_id)
file_id, job_id = await jobs.make_file_job()
service = _FakeService(job_id, result_subject)
await core.start()
try:
@ -834,6 +982,7 @@ async def test_full_cycle_over_real_nats(session_factory, jobs: CommittedJobs, l
await service.send(envelope(job_id, file_id, "processing"), ack_msg_id(job_id, 1))
job = await _wait_status(session_factory, job_id, RecognitionStatus.processing)
assert job["started_at"] is not None
assert (await _file_row(session_factory, file_id))["status"] is FileStatus.PROCESSING
assert (job["service_name"], job["service_version"]) == ("tabular-parser", "0.1.0")
result = envelope(job_id, file_id, "done", metrics={"duration_ms": 10})
@ -843,6 +992,7 @@ async def test_full_cycle_over_real_nats(session_factory, jobs: CommittedJobs, l
assert (job["result_s3_object_id"], job["result_msg_id"]) == (s3["id"], f"result:{job_id}:1")
assert job["finished_at"] is not None
assert s3["bucket_role"] is S3BucketRole.results
assert (await _file_row(session_factory, file_id))["status"] is FileStatus.PROCESSING
await service.send(result, result_msg_id(job_id, 1))

View File

@ -137,9 +137,9 @@ async def set_file_status_for_job(session, job: RecognitionJob, status: FileStat
```
Ошибка задания (`error`/`timeout`/`dlq`) ставит файлу `ERROR`; ack — `PROCESSING` только из `UPLOADED`; `done`/`needs_check` файл не трогают.
- [ ] **Step 1: Тесты** (integration, `CommittedJobs` + `make_stored_file`): ack → `PROCESSING`; `error` → `ERROR`; `done` → файл `PROCESSING`; `timeout` вахтёром → `ERROR`; `dlq` публикатором → `ERROR`; результат задания, которое уже не `current_recognition_job_id` файла → файл не меняется (Review Focus 1); существующие тесты inbox без `mapper` зелёные.
- [ ] **Step 2–4:** падает → реализация → `pytest -m integration tests/test_recognition_inbox.py tests/test_recognition_exchange.py -q`, `make check`.
- [ ] **Step 5: Commit** `feat(exchange): статусы stored_file по ack/ошибке/таймауту/dlq; inbox без ResultMapper (OQ-67)`.
- [x] **Step 1: Тесты** (integration, `CommittedJobs` + `make_stored_file`): ack → `PROCESSING`; `error` → `ERROR`; `done` → файл `PROCESSING`; `timeout` вахтёром → `ERROR`; `dlq` публикатором → `ERROR`; результат задания, которое уже не `current_recognition_job_id` файла → файл не меняется (Review Focus 1); существующие тесты inbox без `mapper` зелёные.
- [x] **Step 2–4:** падает → реализация → `pytest -m integration tests/test_recognition_inbox.py tests/test_recognition_exchange.py -q`, `make check`.
- [x] **Step 5: Commit** `feat(exchange): статусы stored_file по ack/ошибке/таймауту/dlq; inbox без ResultMapper (OQ-67)`.
---

View File

@ -23,7 +23,7 @@
`schemas/files.py` (`UploadResult{accepted,rejected}`, `FileOut`, `JobBrief`, `DocumentBrief`), эндпоинт
`POST /legal-entities/{id}/files`, роутер. Тесты: сценарии спеки `primary-files` (принята, смесь, сигнатура, чужое ЮЛ, дубль);
`fixtures`-тест: реальный xls принят и `publish_pending` на `FakeNats` публикует запрос с ключом `uploads/{file_id}/{job_id}/…`.
- [ ] 2.2 **Статусы файла в обмене.** `recognition_exchange.py`: ack → `PROCESSING`; `error` → `ERROR`; вахтёр `timeout` → `ERROR`;
- [x] 2.2 **Статусы файла в обмене.** `recognition_exchange.py`: ack → `PROCESSING`; `error` → `ERROR`; вахтёр `timeout` → `ERROR`;
публикатор `dlq` → `ERROR` (все — только при `current_recognition_job_id = job`); `ResultMapper`/`NoopMapper` удалены,
`_map_result` убран, тесты inbox обновлены (`RecordingMapper` → проверка статуса файла). Тесты: сценарии спеки
`recognition-job-exchange` (ack → PROCESSING, error → ERROR, timeout → ERROR, done → файл остаётся PROCESSING).