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