arm-core/api/src/services/recognition_exchange.py
Andrey Epifancev 5ae8ff8a81 nats: subject'ы как в старом АРМ — files.*_uploaded, files.tabular_parsed (#77)
Решение владельца 08.10.2026: recognition.tabular.request → files.tabular_uploaded,
recognition.image.request → files.img_uploaded, recognition.result →
files.tabular_parsed (общий subject ack и результатов; files.img_parsed не
используется). Тела сообщений, Nats-Msg-Id, queue group'ы и schema_version не
меняются. Константы api/src/contract/subjects.py, докстринги, тесты, README,
CLAUDE.md; копии схем api/contracts/ из docs/contracts/ побайтно; change
nats-files-subjects с дельтой спеки. Сквозной change — RSHB/arm-ka-docs#74.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2
2026-10-08 23:46:18 +04:00

802 lines
40 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Обмен с сервисами распознавания через `recognition_job` (design D-6; spec recognition-job-exchange;
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`, файл текущего
задания → `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.
Гарантия доставки — at-least-once за счёт повторов без ack, а не за счёт `flush`: успешный `NatsConnection.publish`
означает лишь, что сервер ответил на PING после отправки, но не что принял именно это сообщение (ошибка прав, например,
приходит асинхронно в `error_cb`). Сбой commit после успешной публикации тоже даёт повтор того же сообщения. Поэтому
сервисы распознавания идемпотентны по `job_id` + `attempt` (`Nats-Msg-Id`).
Модель транзакций публикатора: одно задание — одна короткая транзакция (`SELECT … LIMIT 1 FOR UPDATE OF recognition_job
SKIP LOCKED` → публикация → `UPDATE` → `COMMIT`). Блокировка строки держится на время одной публикации, а не пачки;
параллельный проход (второй инстанс ядра) пропускает заблокированную строку и не публикует её повторно, а после commit
строка уже не подходит под условие выборки.
Обмен не пишет `audit_event` (рулинг R-7, design #20; ASSUMPTION(OQ-55)).
Inbox (задача 24, рулинги R-12, R-22): `handle_message()` — обработчик подписки `files.tabular_parsed`
(`src/core/nats.py`, queue group `arm-core`). Одно сообщение — одна короткая транзакция без сетевого IO:
`SELECT … FOR UPDATE` строки задания (ЖДЁТ блокировку: ack, пришедший, пока публикатор держит строку до своего commit,
применяется после него, а не теряется) → проверки статуса → изменения → `COMMIT`.
- 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`), обновление задания, статус файла и 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`, ошибка БД — лог без повторов; исключения наружу не выходят.
Задание без применённого результата дойдёт до `timeout` (осознанный компромисс D-6).
"""
import asyncio
import json
import uuid
from collections.abc import Awaitable, Callable, Mapping, Sequence
from dataclasses import dataclass
from datetime import UTC, datetime, timedelta
from typing import Any, Protocol
from loguru import logger
from pydantic import ValidationError
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
from src.contract import (
CONTRACT_VERSION,
NATS_MSG_ID_HEADER,
MessageStatus,
Metrics,
RecognitionRequest,
RecognitionResultMessage,
S3Ref,
ack_msg_id,
request_msg_id,
request_subject,
result_msg_id,
)
from src.core.config import Settings
from src.core.nats import NatsPublishError
from src.core.storage import ObjectStorage
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
from src.services.result_mapping import run_mapping_loop
WATCHDOG_PERIOD_SECONDS = 30 # период вахтёра сроков (spec «Таймаут ожидания результата»)
PUBLISH_BATCH_SIZE = 50 # заданий за один проход публикатора (design D-6, LIMIT 50)
# Срок ответа сервиса, если задание создано без `reply_deadline`: OCR — 5 мин (DBML note), парсер — так же
DEFAULT_REPLY_TIMEOUT: dict[RecognitionPipeline, timedelta] = {
RecognitionPipeline.tabular: timedelta(minutes=5),
RecognitionPipeline.image: timedelta(minutes=5),
}
REPUBLISH_COUNT_KEY = "republish_count" # счётчик повторов в `recognition_job.metrics` (design #6)
ERROR_NO_ACK = "NO_ACK"
ERROR_TIMEOUT = "TIMEOUT"
ERROR_INVALID_REQUEST = "INVALID_REQUEST"
ERROR_MESSAGE_MAX_LENGTH = 2000
SleepFn = Callable[[float], Awaitable[None]]
Clock = Callable[[], datetime]
class RequestPublisher(Protocol):
"""То, что публикатору нужно от соединения NATS (`src.core.nats.NatsConnection`)."""
@property
def is_connected(self) -> bool: ...
async def publish(self, subject: str, payload: bytes, msg_id: str) -> None: ...
@dataclass
class PublishReport:
"""Итог одного прохода публикатора."""
published: int = 0 # первая публикация: queued → published
republished: int = 0 # повторная публикация без ack
dlq: int = 0 # повторы исчерпаны: published → dlq
invalid: int = 0 # запрос не собирается по контракту: → error / INVALID_REQUEST
failed: int = 0 # ошибка публикации (проход прерван)
skipped: bool = False # нет соединения с NATS: проход не выполнялся
@property
def total(self) -> int:
"""Сколько заданий изменено за проход."""
return self.published + self.republished + self.dlq + self.invalid
def _utcnow() -> datetime:
return datetime.now(UTC)
def _republish_count(metrics: Any) -> int:
"""`metrics.republish_count` (нет поля / `metrics` NULL / не число — 0)."""
if isinstance(metrics, dict):
value = metrics.get(REPUBLISH_COUNT_KEY)
if isinstance(value, int) and not isinstance(value, bool):
return value
return 0
def _candidate_stmt(now: datetime, ack_cutoff: datetime) -> Any:
"""Следующее задание прохода: `queued` или `published` без ack дольше таймаута (срок ответа ещё не прошёл —
иначе задание за вахтёром). Строка задания блокируется, занятые другим проходом пропускаются."""
job = RecognitionJob
awaiting_ack = and_(
job.status == RecognitionStatus.published,
or_(job.published_at.is_(None), job.published_at < ack_cutoff),
or_(job.reply_deadline.is_(None), job.reply_deadline >= now),
)
return (
select(
job.id,
job.status,
job.pipeline,
job.attempt,
job.reply_deadline,
job.requested_at,
job.metrics,
job.request_msg_id,
StoredFile.id.label("file_id"),
StoredFile.file_type,
StoredFile.original_name,
S3Object.bucket,
S3Object.key,
)
.join(StoredFile, StoredFile.id == job.file_id)
.join(S3Object, S3Object.id == StoredFile.s3_object_id)
.where(or_(job.status == RecognitionStatus.queued, awaiting_ack))
.order_by(job.requested_at.asc().nulls_first(), job.id)
.limit(1)
.with_for_update(of=job, skip_locked=True)
)
def _job_update(job_id: uuid.UUID, expected: RecognitionStatus) -> Any:
"""`UPDATE recognition_job` только из ожидаемого статуса (страховка от гонки с обработчиком результата)."""
return (
update(RecognitionJob)
.where(RecognitionJob.id == job_id, RecognitionJob.status == expected)
.execution_options(synchronize_session=False)
)
def _build_request(row: Any, reply_deadline: datetime) -> RecognitionRequest:
"""Запрос `recognition-request.schema.json` из `recognition_job ⋈ stored_file ⋈ s3_object`."""
return RecognitionRequest(
schema_version=CONTRACT_VERSION,
job_id=row.id,
file_id=row.file_id,
pipeline=row.pipeline.value,
file_type=row.file_type.value,
original_name=row.original_name,
s3=S3Ref(bucket=row.bucket, key=row.key),
reply_deadline=reply_deadline,
attempt=row.attempt,
)
def _short(text: str) -> str:
return text if len(text) <= ERROR_MESSAGE_MAX_LENGTH else text[: ERROR_MESSAGE_MAX_LENGTH - 1] + "…"
async def _process_one( # noqa: PLR0913 - один шаг прохода: все параметры прохода
session: AsyncSession,
nats: RequestPublisher,
report: PublishReport,
*,
clock: Clock,
ack_cutoff: datetime,
pass_started: datetime,
max_republish: int,
) -> bool:
"""Обработать одно задание в транзакции `session`; `False` — подходящих заданий больше нет."""
row = (await session.execute(_candidate_stmt(pass_started, ack_cutoff))).one_or_none()
if row is None:
return False
now = clock()
first = row.status is RecognitionStatus.queued
if not first and _republish_count(row.metrics) >= max_republish:
await session.execute(
_job_update(row.id, RecognitionStatus.published).values(
status=RecognitionStatus.dlq,
error_code=ERROR_NO_ACK,
error_message=(
f"Сервис распознавания не подтвердил получение запроса (ack) после {max_republish} "
"повторных публикаций"
),
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}, "
"republish_count={count}",
job_id=row.id,
count=_republish_count(row.metrics),
)
return True
reply_deadline = row.reply_deadline or now + DEFAULT_REPLY_TIMEOUT[row.pipeline]
try:
request = _build_request(row, reply_deadline)
subject = request_subject(row.pipeline.value)
except (ValidationError, ValueError) as exc:
await session.execute(
_job_update(row.id, row.status).values(
status=RecognitionStatus.error,
error_code=ERROR_INVALID_REQUEST,
error_message=_short(f"Запрос распознавания не соответствует контракту: {exc}"),
finished_at=now,
)
)
# финальная ошибка задания — файл текущего задания ERROR (spec primary-files «Жизненный цикл статуса файла»)
await set_file_status_for_job(session, row, FileStatus.ERROR, now=now)
report.invalid += 1
logger.error(
"Запрос распознавания не собран по контракту, задание переведено в error. "
"error_category=invalid_request, job_id={job_id}, error={error}",
job_id=row.id,
error=str(exc),
)
return True
# первая публикация — id из текущего attempt; повтор — сохранённый id (тот же Nats-Msg-Id)
msg_id = request_msg_id(row.id, row.attempt) if first else row.request_msg_id or request_msg_id(row.id, row.attempt)
payload = json.dumps(request.to_wire(), ensure_ascii=False).encode("utf-8")
await nats.publish(subject, payload, msg_id) # NatsPublishError → откат транзакции у вызывающего
if first:
await session.execute(
_job_update(row.id, RecognitionStatus.queued).values(
status=RecognitionStatus.published,
published_at=now,
request_msg_id=msg_id,
request_subject=subject,
reply_deadline=reply_deadline,
requested_at=row.requested_at or now,
)
)
report.published += 1
logger.info(
"Запрос распознавания опубликован. job_id={job_id}, subject={subject}, msg_id={msg_id}",
job_id=row.id,
subject=subject,
msg_id=msg_id,
)
else:
count = _republish_count(row.metrics) + 1
# jsonb `||`: прочие ключи metrics сохраняются; не объект (SQL NULL или jsonb null — строки, записанные до
# JSONB(none_as_null=True) или сырым SQL) → {}
is_object = func.jsonb_typeof(RecognitionJob.metrics) == "object"
base = case((is_object, RecognitionJob.metrics), else_=literal({}, JSONB))
metrics = base.op("||", return_type=JSONB)(literal({REPUBLISH_COUNT_KEY: count}, JSONB))
await session.execute(
_job_update(row.id, RecognitionStatus.published).values(
published_at=now, reply_deadline=reply_deadline, metrics=metrics
)
)
report.republished += 1
logger.warning(
"Нет ack от сервиса распознавания, запрос опубликован повторно. job_id={job_id}, subject={subject}, "
"msg_id={msg_id}, republish_count={count}",
job_id=row.id,
subject=subject,
msg_id=msg_id,
count=count,
)
return True
async def publish_pending( # noqa: PLR0913 - параметры прохода именованные: настройки и подмены для тестов
session_factory: async_sessionmaker[AsyncSession],
nats: RequestPublisher,
*,
ack_timeout: timedelta,
max_republish: int,
batch_size: int = PUBLISH_BATCH_SIZE,
now: Clock | None = None,
) -> PublishReport:
"""Один проход outbox-публикатора (spec «Outbox публикует задания из `queued`», «Запрос без ack публикуется
повторно, затем — `dlq`»; design D-6).
До `batch_size` заданий, каждое — в своей транзакции (см. docstring модуля). Нет соединения с NATS — проход
пропускается (задания остаются как есть). Первая `NatsPublishError` откатывает транзакцию задания (оно остаётся
`queued` / `published` без изменений) и прерывает проход: следующий проход повторит публикацию.
"""
clock = now or _utcnow
report = PublishReport()
if not nats.is_connected:
report.skipped = True
return report
pass_started = clock()
ack_cutoff = pass_started - ack_timeout
for _ in range(batch_size):
try:
async with session_factory() as session, session.begin():
found = await _process_one(
session,
nats,
report,
clock=clock,
ack_cutoff=ack_cutoff,
pass_started=pass_started,
max_republish=max_republish,
)
except NatsPublishError as exc:
report.failed += 1
logger.warning(
"Публикация запроса распознавания не удалась, повтор в следующем проходе. error_category=nats_error, "
"error={error}",
error=str(exc),
)
break
if not found:
break
return report
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 … RETURNING id` с условием по
статусу: результат, успевший перевести задание в финальный статус, не перетирается. В той же транзакции файлы этих
заданий (только где задание — текущее, `current_recognition_job_id`) → `ERROR`. Возвращает число заданий."""
moment = (now or _utcnow)()
stmt = (
update(RecognitionJob)
.where(
RecognitionJob.status.in_([RecognitionStatus.processing, RecognitionStatus.published]),
RecognitionJob.reply_deadline < moment,
)
.values(
status=RecognitionStatus.timeout,
error_code=ERROR_TIMEOUT,
error_message="Сервис распознавания не прислал результат до срока ответа (reply_deadline)",
finished_at=moment,
)
.returning(RecognitionJob.id)
.execution_options(synchronize_session=False)
)
async with session_factory() as session, session.begin():
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}, files_error={files}",
count=count,
files=files,
)
return count
async def run_publisher_loop( # noqa: PLR0913 - настройки цикла именованные
session_factory: async_sessionmaker[AsyncSession],
nats: RequestPublisher,
*,
poll_seconds: float,
ack_timeout: timedelta,
max_republish: int,
sleep: SleepFn = asyncio.sleep,
) -> None:
"""Бесконечный цикл публикатора с периодом `poll_seconds`; ошибка прохода логируется, цикл продолжается."""
while True:
try:
await publish_pending(session_factory, nats, ack_timeout=ack_timeout, max_republish=max_republish)
except Exception as exc:
logger.exception(
"Ошибка прохода outbox-публикатора. error_category=outbox_error, error_type={error_type}, "
"error={error}",
error_type=type(exc).__name__,
error=repr(exc),
)
await sleep(poll_seconds)
async def run_watchdog_loop(
session_factory: async_sessionmaker[AsyncSession],
*,
period_seconds: float = WATCHDOG_PERIOD_SECONDS,
sleep: SleepFn = asyncio.sleep,
) -> None:
"""Бесконечный цикл вахтёра сроков с периодом `period_seconds`; ошибка прохода логируется, цикл продолжается."""
while True:
try:
await expire_deadlines(session_factory)
except Exception as exc:
logger.exception(
"Ошибка прохода вахтёра сроков. error_category=watchdog_error, error_type={error_type}, error={error}",
error_type=type(exc).__name__,
error=repr(exc),
)
await sleep(period_seconds)
class ExchangeTasks:
"""Фоновые задачи обмена в lifespan (design D-6; Срез 2c D-1): публикатор, вахтёр и — при подключённом S3 — цикл
маппинга результатов (`services/result_mapping.py`). Запускаются только при `NATS_ENABLED=true` (вызывающий код
создаёт их вместе с `NatsConnection`) и останавливаются до закрытия NATS."""
def __init__(self, tasks: Sequence[asyncio.Task[None]]) -> None:
self.tasks = tuple(tasks)
@classmethod
def start(
cls,
session_factory: async_sessionmaker[AsyncSession],
nats: RequestPublisher,
settings: Settings,
storage: ObjectStorage | None = None,
) -> "ExchangeTasks":
publisher = asyncio.create_task(
run_publisher_loop(
session_factory,
nats,
poll_seconds=settings.OUTBOX_POLL_SECONDS,
ack_timeout=timedelta(seconds=settings.ACK_TIMEOUT_SECONDS),
max_republish=settings.OUTBOX_MAX_REPUBLISH,
),
name="recognition-outbox-publisher",
)
watchdog = asyncio.create_task(
run_watchdog_loop(session_factory, period_seconds=WATCHDOG_PERIOD_SECONDS),
name="recognition-deadline-watchdog",
)
tasks = [publisher, watchdog]
if storage is not None:
tasks.append(
asyncio.create_task(
run_mapping_loop(session_factory, storage, settings), name="recognition-result-mapper"
)
)
else:
logger.warning("S3 не подключено: маппинг результатов не запущен")
logger.info(
"Фоновые задачи обмена запущены: публикатор (период {poll} с), вахтёр сроков (период {watchdog} с), "
"маппинг результатов: {mapping}",
poll=settings.OUTBOX_POLL_SECONDS,
watchdog=WATCHDOG_PERIOD_SECONDS,
mapping=f"период {settings.MAPPING_POLL_SECONDS} с" if storage is not None else "нет",
)
return cls(tasks)
async def stop(self) -> None:
"""Отменяет задачи и дожидается их завершения; исключения наружу не выходят."""
for task in self.tasks:
task.cancel()
results = await asyncio.gather(*self.tasks, return_exceptions=True)
for task, result in zip(self.tasks, results, strict=True):
if isinstance(result, Exception): # CancelledError — штатная остановка, не Exception
logger.opt(exception=result).error(
"Фоновая задача обмена завершилась с ошибкой. task={name}", name=task.get_name()
)
# --- inbox: files.tabular_parsed ----------------------------------------------------------------------------------
# Финальные статусы для inbox (рулинг R-22, design #34): результат для них не применяется
FINAL_STATUSES: frozenset[RecognitionStatus] = frozenset(
{
RecognitionStatus.done,
RecognitionStatus.needs_check,
RecognitionStatus.error,
RecognitionStatus.timeout,
RecognitionStatus.dlq,
}
)
# Итог сервиса → статус задания
RESULT_STATUSES: Mapping[MessageStatus, RecognitionStatus] = {
MessageStatus.DONE: RecognitionStatus.done,
MessageStatus.NEEDS_CHECK: RecognitionStatus.needs_check,
MessageStatus.ERROR: RecognitionStatus.error,
}
# Счётчики значений из метрик сервиса → одноимённые колонки задания (DBML v5.7)
FIELD_COUNTERS = ("fields_total", "fields_low_confidence", "fields_unrecognized")
LOG_ID_MAX_LENGTH = 64 # job_id невалидного сообщения в логе — не длиннее (тело в лог не пишется)
class InboundMessage(Protocol):
"""То, что inbox читает из сообщения nats-py (`nats.aio.msg.Msg`)."""
@property
def data(self) -> bytes: ...
@property
def headers(self) -> Mapping[str, str] | None: ...
@property
def subject(self) -> str: ...
def header_msg_id(headers: Mapping[str, str] | None) -> str | None:
"""Значение заголовка `Nats-Msg-Id` без учёта регистра имени; нет заголовка или пустое значение — `None`."""
if not headers:
return None
target = NATS_MSG_ID_HEADER.lower()
for name, value in headers.items():
if name.lower() == target and value:
return value
return None
def merge_metrics(existing: Any, incoming: Mapping[str, Any] | None) -> Any:
"""Метрики сервиса поверх метрик задания: ключи ядра (`republish_count`) сохраняются, совпадающие — от сервиса.
`incoming is None` — метрики задания как есть. Не объект (SQL NULL; jsonb `null` в строках, записанных до
`JSONB(none_as_null=True)` или сырым SQL) — как `{}`.
"""
if incoming is None:
return existing
base = dict(existing) if isinstance(existing, dict) else {}
return {**base, **incoming}
def _parse_message(msg: InboundMessage) -> RecognitionResultMessage | None:
"""Конверт `recognition-result-message` v1.0 или `None` (warning в лог без тела сообщения)."""
try:
data = json.loads(msg.data)
except ValueError: # JSONDecodeError и UnicodeDecodeError
data = None
if not isinstance(data, dict):
logger.warning(
"Сообщение {subject} отклонено: тело не JSON-объект. error_category=invalid_message, size={size}",
subject=msg.subject,
size=len(msg.data),
)
return None
try:
return RecognitionResultMessage.model_validate(data)
except ValidationError as exc:
raw_job_id = data.get("job_id")
# job_id невалидного сообщения — данные отправителя: repr экранирует переводы строк и управляющие символы
job_id = repr(raw_job_id[:LOG_ID_MAX_LENGTH]) if isinstance(raw_job_id, str) else None
errors = "; ".join(
f"{'.'.join(str(part) for part in error['loc']) or '<root>'}: {error['msg']}"
for error in exc.errors(include_url=False, include_input=False, include_context=False)
)
logger.warning(
"Сообщение {subject} отклонено: не соответствует контракту recognition-result-message. "
"error_category=invalid_message, job_id={job_id}, errors={errors}",
subject=msg.subject,
job_id=job_id,
errors=errors,
)
return None
async def _lock_job(session: AsyncSession, job_id: uuid.UUID) -> RecognitionJob | None:
"""Строка задания под `FOR UPDATE` — ЖДЁТ блокировку (не SKIP LOCKED / NOWAIT): ack или результат, пришедший, пока
публикатор или вахтёр держат строку, применяется после их commit и видит их итог."""
stmt = (
select(RecognitionJob)
.where(RecognitionJob.id == job_id)
.with_for_update()
.execution_options(populate_existing=True)
)
return (await session.execute(stmt)).scalar_one_or_none()
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}, 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)
else:
logger.warning(
"Ack для задания не в статусе published: задание не изменено. error_category=unexpected_ack, "
"job_id={job_id}, status={status}, msg_id={msg_id}",
job_id=job.id,
status=job.status.value,
msg_id=msg_id,
)
async def _register_result_object(session: AsyncSession, message: RecognitionResultMessage, now: datetime) -> uuid.UUID:
"""Файл результата в реестре `s3_object` (`bucket_role = results`, `created_by = service.name`).
Уникальность `(bucket, key)`: строка с этим ключом уже есть — переиспользуется (дубль не вставляется, не падаем).
"""
assert message.result_s3 is not None
bucket, key = message.result_s3.bucket, message.result_s3.key
stmt = (
pg_insert(S3Object)
.values(
bucket_role=S3BucketRole.results,
bucket=bucket,
key=key,
created_by=message.service.name.value,
created_at=now,
)
.on_conflict_do_nothing(index_elements=[S3Object.bucket, S3Object.key])
.returning(S3Object.id)
)
inserted = (await session.execute(stmt)).scalar_one_or_none()
if inserted is not None:
return inserted
existing = (
await session.execute(select(S3Object.id).where(S3Object.bucket == bucket, S3Object.key == key))
).scalar_one()
logger.warning(
"Файл результата уже зарегистрирован в s3_object, строка переиспользована. job_id={job_id}, bucket={bucket}, "
"key={key}",
job_id=message.job_id,
bucket=bucket,
key=key,
)
return existing
async def _apply_result(
session: AsyncSession, job: RecognitionJob, message: RecognitionResultMessage, msg_id: str, now: datetime
) -> bool:
"""Результат (`done | needs_check | error`): дубликат и финальный статус — без изменений (`False`); иначе —
регистрация файла результата, обновление задания и статуса файла (`True`, дальше — commit)."""
if job.result_msg_id == msg_id:
logger.debug(
"Повторная доставка результата (дубликат по result_msg_id), изменений нет. job_id={job_id}, "
"msg_id={msg_id}",
job_id=job.id,
msg_id=msg_id,
)
return False
if job.status in FINAL_STATUSES:
logger.warning(
"Результат для задания в финальном статусе не применён. error_category=late_result, job_id={job_id}, "
"status={status}, result_status={result_status}, msg_id={msg_id}",
job_id=job.id,
status=job.status.value,
result_status=message.status.value,
msg_id=msg_id,
)
return False
result_s3_object_id = await _register_result_object(session, message, now) if message.result_s3 else None
job.status = RESULT_STATUSES[message.status]
job.service_name = message.service.name.value
job.service_version = message.service.version
job.finished_at = now
if message.metrics is not None:
job.metrics = merge_metrics(job.metrics, _metrics_json(message.metrics))
for name in FIELD_COUNTERS:
value = getattr(message.metrics, name)
if value is not None:
setattr(job, name, value)
job.error_code = message.error.code.value if message.error is not None else None
job.error_message = message.error.message if message.error is not None else None
job.result_schema_version = message.result_schema_version
job.result_s3_object_id = result_s3_object_id
job.result_msg_id = msg_id
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}, file_changed={file_changed}",
job_id=job.id,
status=job.status.value,
service=message.service.name.value,
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]:
return metrics.model_dump(mode="json", exclude_none=True)
async def _apply_message(
session: AsyncSession, message: RecognitionResultMessage, header_id: str | None, clock: Clock
) -> None:
job = await _lock_job(session, message.job_id)
if job is None:
logger.warning(
"Сообщение files.tabular_parsed для неизвестного задания, не применено. error_category=unknown_job, "
"job_id={job_id}, status={status}",
job_id=message.job_id,
status=message.status.value,
)
return
if job.file_id != message.file_id:
logger.warning(
"Сообщение files.tabular_parsed: file_id не совпадает с заданием, не применено. "
"error_category=file_mismatch, job_id={job_id}, file_id={file_id}, message_file_id={message_file_id}",
job_id=job.id,
file_id=job.file_id,
message_file_id=message.file_id,
)
return
is_ack = message.status is MessageStatus.PROCESSING
msg_id = header_id
if msg_id is None:
msg_id = ack_msg_id(job.id, job.attempt) if is_ack else result_msg_id(job.id, job.attempt)
logger.warning(
"Сообщение files.tabular_parsed без заголовка Nats-Msg-Id: использован производный id. "
"error_category=missing_msg_id, job_id={job_id}, msg_id={msg_id}",
job_id=job.id,
msg_id=msg_id,
)
now = clock()
if is_ack:
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],
now: Clock | None = None,
) -> None:
"""Обработчик `files.tabular_parsed` (spec «Ack сервиса переводит задание в `processing`», «Inbox принимает
результат идемпотентно»; design D-6). Модель транзакции и правила — в docstring модуля.
Исключения наружу не выходят: невалидное сообщение — 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), now or _utcnow)
except Exception as exc:
logger.exception(
"Ошибка обработки сообщения {subject}, транзакция откачена (без повторов). error_category=inbox_error, "
"job_id={job_id}, error_type={error_type}, error={error}",
subject=msg.subject,
job_id=message.job_id,
error_type=type(exc).__name__,
error=repr(exc),
)