188 lines
8.3 KiB
Python
188 lines
8.3 KiB
Python
"""Ручные новые попытки 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
|