arm-core/api/src/services/file_retry.py

188 lines
8.3 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.

"""Ручные новые попытки tabular; очередь и исходный объект S3 переиспользуются."""
import uuid
from typing import Any
from sqlalchemy import exists, select
from sqlalchemy.ext.asyncio import AsyncSession
from src.api.dependencies.audit import RequestAudit
from src.common.clock import utc_now
from src.common.exceptions import ConflictError, NotFoundError
from src.contract import CONTRACT_VERSION, request_subject
from src.core.config import Settings, get_settings
from src.domain.policy import IN_WORK_STATUSES, VisibilityScope
from src.models.consolidation import ConsolidationMember, ConsolidationVersion
from src.models.enums import AuditAction, AuditSection, FileStatus, RecognitionPipeline, RecognitionStatus
from src.models.files import Document, RecognitionJob, StoredFile
from src.repositories import consolidations as versions_repo
from src.repositories import files as files_repo
from src.schemas.files import FileOut, RetryAccepted, RetryBatchOut, RetrySkipped
from src.services.documents import file_out
class FileRetryService:
def __init__(
self, session: AsyncSession, audit: RequestAudit, scope: VisibilityScope, settings: Settings | None = None
) -> None:
self.session, self.audit, self.scope = session, audit, scope
self.settings = settings if settings is not None else get_settings()
async def _lock_files(self, files: list[StoredFile]) -> list[tuple[StoredFile, RecognitionJob | None]]:
expected = {file.id: file.current_recognition_job_id for file in files}
jobs = list(
(
await self.session.scalars(
select(RecognitionJob)
.where(RecognitionJob.id.in_([jid for jid in expected.values() if jid is not None]))
.order_by(RecognitionJob.id)
.with_for_update()
.execution_options(populate_existing=True)
)
).all()
)
by_id = {job.id: job for job in jobs}
locked = list(
(
await self.session.scalars(
select(StoredFile)
.where(StoredFile.id.in_(expected))
.order_by(StoredFile.id)
.with_for_update()
.execution_options(populate_existing=True)
)
).all()
)
return [
(
file,
by_id.get(file.current_recognition_job_id)
if file.current_recognition_job_id is not None and file.current_recognition_job_id == expected[file.id]
else None,
)
for file in locked
]
async def _reason(self, file: StoredFile, job: RecognitionJob | None) -> str | None:
if (
job is None
or file.status is not FileStatus.ERROR
or job.pipeline is not RecognitionPipeline.tabular
or (job.status, job.error_code)
not in {(RecognitionStatus.timeout, "TIMEOUT"), (RecognitionStatus.dlq, "NO_ACK")}
or await self.session.scalar(select(exists().where(Document.stored_file_id == file.id)))
):
return "FILE_NOT_RETRYABLE"
if job.attempt >= job.max_attempts:
return "MAX_ATTEMPTS_REACHED"
return None
async def _new(self, file: StoredFile, job: RecognitionJob, consolidation_id: uuid.UUID | None) -> RecognitionJob:
now = utc_now()
new = RecognitionJob(
file_id=file.id,
pipeline=job.pipeline,
request_subject=request_subject(job.pipeline.value, self.settings.OPENBAO.NATS.request_subjects()),
schema_version=CONTRACT_VERSION,
status=RecognitionStatus.queued,
attempt=job.attempt + 1,
max_attempts=job.max_attempts,
requested_at=now,
)
self.session.add(new)
await self.session.flush()
file.current_recognition_job_id = new.id
file.status, file.updated_at = FileStatus.UPLOADED, now
after: dict[str, Any] = {"job_id": str(new.id), "attempt": new.attempt}
if consolidation_id is not None:
after["consolidation_id"] = str(consolidation_id)
await self.audit.record(
action=AuditAction.FILE_REPROCESS,
section=AuditSection.PRIMARY_DOCUMENTS,
object_type="stored_file",
object_id=file.id,
before_value={
"job_id": str(job.id),
"attempt": job.attempt,
"status": job.status.value,
"error_code": job.error_code,
},
after_value=after,
)
return new
async def retry(self, file_id: uuid.UUID) -> FileOut:
visible = await files_repo.get_file(self.session, self.scope, file_id)
if visible is None:
raise NotFoundError("файл не найден")
locked = await self._lock_files([visible])
if not locked:
raise NotFoundError("файл не найден")
file, job = locked[0]
reason = await self._reason(file, job)
if reason:
raise ConflictError(reason, "Повторная обработка файла недоступна")
if file.consolidation_id is not None:
versions = list(
(
await self.session.scalars(
select(ConsolidationVersion)
.where(
ConsolidationVersion.consolidation_id == file.consolidation_id,
ConsolidationVersion.status.in_(IN_WORK_STATUSES),
)
.order_by(ConsolidationVersion.id)
.with_for_update(read=True)
.execution_options(populate_existing=True)
)
).all()
)
allowed = [v.id for v in versions if v.status in IN_WORK_STATUSES]
member = await self.session.scalar(
select(
exists().where(
ConsolidationMember.consolidation_version_id.in_(allowed),
ConsolidationMember.legal_entity_id == file.legal_entity_id,
)
)
)
if not member:
raise ConflictError("VERSION_NOT_EDITABLE", "Нет версии в работе с организацией файла")
assert job is not None
new = await self._new(file, job, None)
await self.session.flush()
return file_out(file, new, [])
async def retry_version(self, version_id: uuid.UUID) -> RetryBatchOut:
found = await versions_repo.get_version(self.session, self.scope, version_id)
if found is None:
raise NotFoundError("версия не найдена")
_, consolidation = found
members = select(ConsolidationMember.legal_entity_id).where(
ConsolidationMember.consolidation_version_id == version_id
)
candidates = await files_repo.retry_candidates(self.session, self.scope, consolidation.id, version_id)
locked = await self._lock_files(candidates)
version = await versions_repo.lock_version(self.session, version_id)
if version is None:
raise NotFoundError("версия не найдена")
if version.status not in IN_WORK_STATUSES:
raise ConflictError("VERSION_NOT_EDITABLE", "Версия не в работе")
current_members = set((await self.session.scalars(members)).all())
result = RetryBatchOut(accepted=[], skipped=[])
for file, job in locked:
if (
file.legal_entity_id not in current_members
or file.consolidation_id != consolidation.id
or file.status is not FileStatus.ERROR
):
continue
reason = await self._reason(file, job)
if reason:
result.skipped.append(RetrySkipped(file_id=file.id, code=reason))
continue
assert job is not None
new = await self._new(file, job, consolidation.id)
result.accepted.append(RetryAccepted(file_id=file.id, job_id=new.id, attempt=new.attempt))
return result