arm-core/api/src/services/file_status.py
Andrey Epifancev b7ca7285cc 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
2026-10-05 22:08:40 +04:00

86 lines
4.0 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.

"""Статус `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))