AURORA-1442: Правки по раунду 1
This commit is contained in:
parent
98f00c0faa
commit
622a60e4e0
@ -55,10 +55,11 @@ PostgreSQL 16, core NATS без JetStream), `web/` — фронтенд (React +
|
|||||||
OCR, `error` сервиса и ошибки маппинга не повторяются. Ошибки: 409 `FILE_NOT_RETRYABLE`,
|
OCR, `error` сервиса и ошибки маппинга не повторяются. Ошибки: 409 `FILE_NOT_RETRYABLE`,
|
||||||
`MAX_ATTEMPTS_REACHED`, `VERSION_NOT_EDITABLE`.
|
`MAX_ATTEMPTS_REACHED`, `VERSION_NOT_EDITABLE`.
|
||||||
|
|
||||||
Пакет включает файлы с `stored_file.consolidation_id` выбранной консолидации и ЮЛ из периметра выбранной
|
Пакет включает только файлы `ERROR` с `stored_file.consolidation_id` выбранной консолидации и ЮЛ из периметра выбранной
|
||||||
версии; файлы другой консолидации и загрузки без контекста не включаются. Версия должна быть «в работе»
|
версии; файлы другой консолидации и загрузки без контекста не включаются. Версия должна быть «в работе»
|
||||||
(`INITIATED`, `DOCUMENTS_PROCESSING`, `DATA_CORRECTION`), пользовательская блокировка не нужна.
|
(`INITIATED`, `DOCUMENTS_PROCESSING`, `DATA_CORRECTION`), пользовательская блокировка не нужна.
|
||||||
Версии одной консолидации могут видеть общий файл. Неподходящие файлы пропускаются:
|
Версии одной консолидации могут видеть общий файл. Другие статусы файлов не блокируются и не попадают
|
||||||
|
в ответ. Неподходящие `ERROR`-файлы пропускаются:
|
||||||
`accepted: [{file_id, job_id, attempt}]`, `skipped: [{file_id, code}]`. Пустой пакет также отвечает 202.
|
`accepted: [{file_id, job_id, attempt}]`, `skipped: [{file_id, code}]`. Пустой пакет также отвечает 202.
|
||||||
Для одиночного файла консолидации требуется хотя бы одна рабочая версия с ЮЛ файла в периметре.
|
Для одиночного файла консолидации требуется хотя бы одна рабочая версия с ЮЛ файла в периметре.
|
||||||
Повтор не меняет статусы версий. Два конкурентных запроса не создают две новые попытки.
|
Повтор не меняет статусы версий. Два конкурентных запроса не создают две новые попытки.
|
||||||
|
|||||||
@ -66,10 +66,11 @@ StatementServiceDep = Annotated[StatementService, Depends(get_statement_service)
|
|||||||
|
|
||||||
def get_file_retry_service(
|
def get_file_retry_service(
|
||||||
session: SessionDep,
|
session: SessionDep,
|
||||||
|
app_settings: SettingsDep,
|
||||||
audit: Annotated[RequestAudit, Depends(get_request_audit)],
|
audit: Annotated[RequestAudit, Depends(get_request_audit)],
|
||||||
scope: Annotated[VisibilityScope, Depends(get_scope)],
|
scope: Annotated[VisibilityScope, Depends(get_scope)],
|
||||||
) -> FileRetryService:
|
) -> FileRetryService:
|
||||||
return FileRetryService(session, audit, scope)
|
return FileRetryService(session, audit, scope, app_settings)
|
||||||
|
|
||||||
|
|
||||||
FileRetryServiceDep = Annotated[FileRetryService, Depends(get_file_retry_service)]
|
FileRetryServiceDep = Annotated[FileRetryService, Depends(get_file_retry_service)]
|
||||||
|
|||||||
@ -11,13 +11,19 @@ from src.schemas.common import ErrorDetail
|
|||||||
from src.schemas.files import FileOut, RetryBatchOut
|
from src.schemas.files import FileOut, RetryBatchOut
|
||||||
|
|
||||||
router = APIRouter(dependencies=[Depends(require("DOCUMENT_EDIT"))])
|
router = APIRouter(dependencies=[Depends(require("DOCUMENT_EDIT"))])
|
||||||
RESPONSES: dict[int | str, dict[str, Any]] = {
|
FILE_RESPONSES: dict[int | str, dict[str, Any]] = {
|
||||||
404: {"model": ErrorDetail, "description": "Нет или вне видимости"},
|
404: {"model": ErrorDetail, "description": "Нет или вне видимости"},
|
||||||
409: {"model": ErrorDetail, "description": "FILE_NOT_RETRYABLE, MAX_ATTEMPTS_REACHED, VERSION_NOT_EDITABLE"},
|
409: {"model": ErrorDetail, "description": "FILE_NOT_RETRYABLE, MAX_ATTEMPTS_REACHED, VERSION_NOT_EDITABLE"},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@router.post("/files/{file_id}/retry", response_model=FileOut, status_code=202, responses=RESPONSES)
|
BATCH_RESPONSES: dict[int | str, dict[str, Any]] = {
|
||||||
|
404: {"model": ErrorDetail, "description": "Нет или вне видимости"},
|
||||||
|
409: {"model": ErrorDetail, "description": "VERSION_NOT_EDITABLE"},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/files/{file_id}/retry", response_model=FileOut, status_code=202, responses=FILE_RESPONSES)
|
||||||
async def retry_file(file_id: uuid.UUID, service: FileRetryServiceDep) -> FileOut:
|
async def retry_file(file_id: uuid.UUID, service: FileRetryServiceDep) -> FileOut:
|
||||||
"""Новая попытка tabular после TIMEOUT или NO_ACK, до max_attempts."""
|
"""Новая попытка tabular после TIMEOUT или NO_ACK, до max_attempts."""
|
||||||
return await service.retry(file_id)
|
return await service.retry(file_id)
|
||||||
@ -27,7 +33,7 @@ async def retry_file(file_id: uuid.UUID, service: FileRetryServiceDep) -> FileOu
|
|||||||
"/consolidations/versions/{version_id}/files/retry",
|
"/consolidations/versions/{version_id}/files/retry",
|
||||||
response_model=RetryBatchOut,
|
response_model=RetryBatchOut,
|
||||||
status_code=202,
|
status_code=202,
|
||||||
responses=RESPONSES,
|
responses=BATCH_RESPONSES,
|
||||||
)
|
)
|
||||||
async def retry_version_files(version_id: uuid.UUID, service: FileRetryServiceDep) -> RetryBatchOut:
|
async def retry_version_files(version_id: uuid.UUID, service: FileRetryServiceDep) -> RetryBatchOut:
|
||||||
"""Повтор подходящих файлов консолидации по периметру выбранной версии."""
|
"""Повтор подходящих файлов консолидации по периметру выбранной версии."""
|
||||||
|
|||||||
@ -18,7 +18,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|||||||
from src.domain.policy import VisibilityScope, legal_entities_visible
|
from src.domain.policy import VisibilityScope, legal_entities_visible
|
||||||
from src.models.access import AppUser
|
from src.models.access import AppUser
|
||||||
from src.models.audit_ex import Comment
|
from src.models.audit_ex import Comment
|
||||||
from src.models.consolidation import ConsolidationDocument, ConsolidationVersion
|
from src.models.consolidation import ConsolidationDocument, ConsolidationMember, ConsolidationVersion
|
||||||
from src.models.directories import LegalEntity
|
from src.models.directories import LegalEntity
|
||||||
from src.models.enums import DocumentStatus, FileStatus, S3BucketRole
|
from src.models.enums import DocumentStatus, FileStatus, S3BucketRole
|
||||||
from src.models.files import Document, RecognitionJob, S3Object, StoredFile
|
from src.models.files import Document, RecognitionJob, S3Object, StoredFile
|
||||||
@ -298,3 +298,22 @@ async def list_desk_files( # noqa: PLR0913 - фильтр и пагинация
|
|||||||
)
|
)
|
||||||
files = list((await session.execute(page_stmt)).scalars().all())
|
files = list((await session.execute(page_stmt)).scalars().all())
|
||||||
return files, total
|
return files, total
|
||||||
|
|
||||||
|
|
||||||
|
async def retry_candidates(
|
||||||
|
session: AsyncSession, scope: VisibilityScope, consolidation_id: uuid.UUID, version_id: uuid.UUID
|
||||||
|
) -> list[StoredFile]:
|
||||||
|
"""ERROR-файлы консолидации по видимому периметру версии; без блокировки нормальных файлов."""
|
||||||
|
members = select(ConsolidationMember.legal_entity_id).where(
|
||||||
|
ConsolidationMember.consolidation_version_id == version_id
|
||||||
|
)
|
||||||
|
stmt = (
|
||||||
|
_visible_files(select(StoredFile), scope)
|
||||||
|
.where(
|
||||||
|
StoredFile.consolidation_id == consolidation_id,
|
||||||
|
StoredFile.legal_entity_id.in_(members),
|
||||||
|
StoredFile.status == FileStatus.ERROR,
|
||||||
|
)
|
||||||
|
.order_by(StoredFile.id)
|
||||||
|
)
|
||||||
|
return list((await session.scalars(stmt)).all())
|
||||||
|
|||||||
@ -9,6 +9,8 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|||||||
from src.api.dependencies.audit import RequestAudit
|
from src.api.dependencies.audit import RequestAudit
|
||||||
from src.common.clock import utc_now
|
from src.common.clock import utc_now
|
||||||
from src.common.exceptions import ConflictError, NotFoundError
|
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.domain.policy import IN_WORK_STATUSES, VisibilityScope
|
||||||
from src.models.consolidation import ConsolidationMember, ConsolidationVersion
|
from src.models.consolidation import ConsolidationMember, ConsolidationVersion
|
||||||
from src.models.enums import AuditAction, AuditSection, FileStatus, RecognitionPipeline, RecognitionStatus
|
from src.models.enums import AuditAction, AuditSection, FileStatus, RecognitionPipeline, RecognitionStatus
|
||||||
@ -20,8 +22,11 @@ from src.services.documents import file_out
|
|||||||
|
|
||||||
|
|
||||||
class FileRetryService:
|
class FileRetryService:
|
||||||
def __init__(self, session: AsyncSession, audit: RequestAudit, scope: VisibilityScope) -> None:
|
def __init__(
|
||||||
|
self, session: AsyncSession, audit: RequestAudit, scope: VisibilityScope, settings: Settings | None = None
|
||||||
|
) -> None:
|
||||||
self.session, self.audit, self.scope = session, audit, scope
|
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]]:
|
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}
|
expected = {file.id: file.current_recognition_job_id for file in files}
|
||||||
@ -63,7 +68,6 @@ class FileRetryService:
|
|||||||
job is None
|
job is None
|
||||||
or file.status is not FileStatus.ERROR
|
or file.status is not FileStatus.ERROR
|
||||||
or job.pipeline is not RecognitionPipeline.tabular
|
or job.pipeline is not RecognitionPipeline.tabular
|
||||||
# Мб достаточно только по RecognitionStatus?
|
|
||||||
or (job.status, job.error_code)
|
or (job.status, job.error_code)
|
||||||
not in {(RecognitionStatus.timeout, "TIMEOUT"), (RecognitionStatus.dlq, "NO_ACK")}
|
not in {(RecognitionStatus.timeout, "TIMEOUT"), (RecognitionStatus.dlq, "NO_ACK")}
|
||||||
or await self.session.scalar(select(exists().where(Document.stored_file_id == file.id)))
|
or await self.session.scalar(select(exists().where(Document.stored_file_id == file.id)))
|
||||||
@ -78,8 +82,8 @@ class FileRetryService:
|
|||||||
new = RecognitionJob(
|
new = RecognitionJob(
|
||||||
file_id=file.id,
|
file_id=file.id,
|
||||||
pipeline=job.pipeline,
|
pipeline=job.pipeline,
|
||||||
request_subject=job.request_subject,
|
request_subject=request_subject(job.pipeline.value, self.settings.OPENBAO.NATS.request_subjects()),
|
||||||
schema_version=job.schema_version,
|
schema_version=CONTRACT_VERSION,
|
||||||
status=RecognitionStatus.queued,
|
status=RecognitionStatus.queued,
|
||||||
attempt=job.attempt + 1,
|
attempt=job.attempt + 1,
|
||||||
max_attempts=job.max_attempts,
|
max_attempts=job.max_attempts,
|
||||||
@ -125,9 +129,10 @@ class FileRetryService:
|
|||||||
select(ConsolidationVersion)
|
select(ConsolidationVersion)
|
||||||
.where(
|
.where(
|
||||||
ConsolidationVersion.consolidation_id == file.consolidation_id,
|
ConsolidationVersion.consolidation_id == file.consolidation_id,
|
||||||
|
ConsolidationVersion.status.in_(IN_WORK_STATUSES),
|
||||||
)
|
)
|
||||||
.order_by(ConsolidationVersion.id)
|
.order_by(ConsolidationVersion.id)
|
||||||
.with_for_update()
|
.with_for_update(read=True)
|
||||||
.execution_options(populate_existing=True)
|
.execution_options(populate_existing=True)
|
||||||
)
|
)
|
||||||
).all()
|
).all()
|
||||||
@ -156,15 +161,7 @@ class FileRetryService:
|
|||||||
members = select(ConsolidationMember.legal_entity_id).where(
|
members = select(ConsolidationMember.legal_entity_id).where(
|
||||||
ConsolidationMember.consolidation_version_id == version_id
|
ConsolidationMember.consolidation_version_id == version_id
|
||||||
)
|
)
|
||||||
candidates = list(
|
candidates = await files_repo.retry_candidates(self.session, self.scope, consolidation.id, version_id)
|
||||||
(
|
|
||||||
await self.session.scalars(
|
|
||||||
files_repo._visible_files(select(StoredFile), self.scope)
|
|
||||||
.where(StoredFile.consolidation_id == consolidation.id, StoredFile.legal_entity_id.in_(members))
|
|
||||||
.order_by(StoredFile.id)
|
|
||||||
)
|
|
||||||
).all()
|
|
||||||
)
|
|
||||||
locked = await self._lock_files(candidates)
|
locked = await self._lock_files(candidates)
|
||||||
version = await versions_repo.lock_version(self.session, version_id)
|
version = await versions_repo.lock_version(self.session, version_id)
|
||||||
if version is None:
|
if version is None:
|
||||||
@ -174,7 +171,11 @@ class FileRetryService:
|
|||||||
current_members = set((await self.session.scalars(members)).all())
|
current_members = set((await self.session.scalars(members)).all())
|
||||||
result = RetryBatchOut(accepted=[], skipped=[])
|
result = RetryBatchOut(accepted=[], skipped=[])
|
||||||
for file, job in locked:
|
for file, job in locked:
|
||||||
if file.legal_entity_id not in current_members or file.consolidation_id != consolidation.id:
|
if (
|
||||||
|
file.legal_entity_id not in current_members
|
||||||
|
or file.consolidation_id != consolidation.id
|
||||||
|
or file.status is not FileStatus.ERROR
|
||||||
|
):
|
||||||
continue
|
continue
|
||||||
reason = await self._reason(file, job)
|
reason = await self._reason(file, job)
|
||||||
if reason:
|
if reason:
|
||||||
|
|||||||
@ -9,17 +9,19 @@ from sqlalchemy import delete, select
|
|||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from src.api.dependencies.audit import RequestAudit
|
from src.api.dependencies.audit import RequestAudit
|
||||||
|
from src.api.dependencies.directories import get_app_settings
|
||||||
from src.common.exceptions import ConflictError
|
from src.common.exceptions import ConflictError
|
||||||
|
from src.contract import CONTRACT_VERSION
|
||||||
from src.domain.policy import VisibilityScope
|
from src.domain.policy import VisibilityScope
|
||||||
from src.models.audit_ex import AuditEvent
|
from src.models.audit_ex import AuditEvent
|
||||||
from src.models.consolidation import ConsolidationMember, ConsolidationVersion
|
from src.models.consolidation import Consolidation, ConsolidationMember, ConsolidationVersion
|
||||||
from src.models.enums import ConsolidationStatus, FileStatus, RecognitionPipeline, RecognitionStatus
|
from src.models.enums import ConsolidationStatus, FileStatus, RecognitionPipeline, RecognitionStatus
|
||||||
from src.models.files import RecognitionJob
|
from src.models.files import RecognitionJob, StoredFile
|
||||||
from src.services.audit import AuditService
|
from src.services.audit import AuditService
|
||||||
from src.services.file_retry import FileRetryService
|
from src.services.file_retry import FileRetryService
|
||||||
from src.services.recognition_exchange import handle_message, publish_pending
|
from src.services.recognition_exchange import handle_message, publish_pending
|
||||||
from tests.conftest import AuthClientFactory
|
from tests.conftest import AuthClientFactory
|
||||||
from tests.factories import make_document, make_stored_file
|
from tests.factories import make_document, make_stored_file, make_version
|
||||||
from tests.test_api_consolidation_members import _user, _version
|
from tests.test_api_consolidation_members import _user, _version
|
||||||
from tests.test_api_files_read import KA_RF, _rostov_entity
|
from tests.test_api_files_read import KA_RF, _rostov_entity
|
||||||
from tests.test_recognition_exchange import FakeNats
|
from tests.test_recognition_exchange import FakeNats
|
||||||
@ -109,7 +111,7 @@ async def test_batch_filters_perimeter_and_skips_ineligible(
|
|||||||
assert response.status_code == HTTPStatus.ACCEPTED, response.text
|
assert response.status_code == HTTPStatus.ACCEPTED, response.text
|
||||||
body = response.json()
|
body = response.json()
|
||||||
assert [row["file_id"] for row in body["accepted"]] == [str(good.id)]
|
assert [row["file_id"] for row in body["accepted"]] == [str(good.id)]
|
||||||
assert body["skipped"] == [{"file_id": str(bad.id), "code": "FILE_NOT_RETRYABLE"}]
|
assert body["skipped"] == []
|
||||||
event = (
|
event = (
|
||||||
await seeded.execute(
|
await seeded.execute(
|
||||||
select(AuditEvent).where(AuditEvent.object_id == good.id, AuditEvent.action == "FILE_REPROCESS")
|
select(AuditEvent).where(AuditEvent.object_id == good.id, AuditEvent.action == "FILE_REPROCESS")
|
||||||
@ -236,7 +238,7 @@ async def test_concurrent_batch_retry(committed_sessionmaker) -> None:
|
|||||||
|
|
||||||
results = await asyncio.wait_for(asyncio.gather(retry(), retry()), timeout=10)
|
results = await asyncio.wait_for(asyncio.gather(retry(), retry()), timeout=10)
|
||||||
assert sorted(len(result.accepted) for result in results) == [0, 1]
|
assert sorted(len(result.accepted) for result in results) == [0, 1]
|
||||||
assert sorted(len(result.skipped) for result in results) == [0, 1]
|
assert all(result.skipped == [] for result in results)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("change", ["status", "perimeter"])
|
@pytest.mark.parametrize("change", ["status", "perimeter"])
|
||||||
@ -343,3 +345,77 @@ async def test_batch_no_ack_at_attempt_limit_is_max_attempts_reached(
|
|||||||
assert response.json() == {"accepted": [], "skipped": [{"file_id": str(file.id), "code": "MAX_ATTEMPTS_REACHED"}]}
|
assert response.json() == {"accepted": [], "skipped": [{"file_id": str(file.id), "code": "MAX_ATTEMPTS_REACHED"}]}
|
||||||
jobs = await seeded.scalars(select(RecognitionJob).where(RecognitionJob.file_id == file.id))
|
jobs = await seeded.scalars(select(RecognitionJob).where(RecognitionJob.file_id == file.id))
|
||||||
assert [item.id for item in jobs.all()] == [job.id]
|
assert [item.id for item in jobs.all()] == [job.id]
|
||||||
|
|
||||||
|
|
||||||
|
async def test_retry_uses_current_contract_and_subject(
|
||||||
|
seeded: AsyncSession, auth_client: AuthClientFactory, monkeypatch: pytest.MonkeyPatch
|
||||||
|
) -> None:
|
||||||
|
entity = await _rostov_entity(seeded)
|
||||||
|
file, old = await make_stored_file(
|
||||||
|
seeded, entity=entity, status=FileStatus.ERROR, job_status=RecognitionStatus.timeout
|
||||||
|
)
|
||||||
|
old.error_code, old.max_attempts = "TIMEOUT", 3
|
||||||
|
old.schema_version, old.request_subject = "0.9", "old.tabular.subject"
|
||||||
|
monkeypatch.setattr(get_app_settings().OPENBAO.NATS, "NATS_TABULAR_REQUEST_SUBJECT", "current.tabular.subject")
|
||||||
|
await seeded.flush()
|
||||||
|
client = await auth_client(KA_RF)
|
||||||
|
response = await client.post(f"/files/{file.id}/retry")
|
||||||
|
assert response.status_code == HTTPStatus.ACCEPTED, response.text
|
||||||
|
await seeded.refresh(file)
|
||||||
|
new = await seeded.get(RecognitionJob, file.current_recognition_job_id)
|
||||||
|
assert new is not None
|
||||||
|
assert new.schema_version == CONTRACT_VERSION and new.request_subject == "current.tabular.subject"
|
||||||
|
await seeded.refresh(old)
|
||||||
|
assert old.schema_version == "0.9" and old.request_subject == "old.tabular.subject"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"normal_status", [FileStatus.UPLOADED, FileStatus.PROCESSING, FileStatus.PROCESSED, FileStatus.SUPERSEDED]
|
||||||
|
)
|
||||||
|
async def test_batch_does_not_lock_normal_files(committed_sessionmaker, normal_status: FileStatus) -> None:
|
||||||
|
async with committed_sessionmaker() as session, session.begin():
|
||||||
|
version, members, _, _ = await _version(session, members=1, locked_by=None)
|
||||||
|
normal, job = await make_stored_file(
|
||||||
|
session, entity=members[0], status=normal_status, job_status=RecognitionStatus.processing
|
||||||
|
)
|
||||||
|
normal.consolidation_id = version.consolidation_id
|
||||||
|
user = await _user(session, KA_RF)
|
||||||
|
version_id, file_id, job_id, user_id = version.id, normal.id, job.id, user.id
|
||||||
|
|
||||||
|
async def retry():
|
||||||
|
async with committed_sessionmaker() as session, session.begin():
|
||||||
|
audit = RequestAudit(AuditService(session), user_id, None, None)
|
||||||
|
return await FileRetryService(session, audit, VisibilityScope(None)).retry_version(version_id)
|
||||||
|
|
||||||
|
async with committed_sessionmaker() as holder, holder.begin():
|
||||||
|
await holder.execute(select(RecognitionJob).where(RecognitionJob.id == job_id).with_for_update())
|
||||||
|
await holder.execute(select(StoredFile).where(StoredFile.id == file_id).with_for_update())
|
||||||
|
result = await asyncio.wait_for(retry(), timeout=5)
|
||||||
|
assert result.accepted == [] and result.skipped == []
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("closed_status", [ConsolidationStatus.VERIFIED, ConsolidationStatus.CANCELLED])
|
||||||
|
async def test_single_retry_does_not_lock_closed_versions(committed_sessionmaker, closed_status) -> None:
|
||||||
|
async with committed_sessionmaker() as session, session.begin():
|
||||||
|
working, members, _, _ = await _version(session, members=1, locked_by=None)
|
||||||
|
consolidation = await session.get(Consolidation, working.consolidation_id)
|
||||||
|
user = await _user(session, KA_RF)
|
||||||
|
closed = await make_version(
|
||||||
|
session, consolidation=consolidation, version_no=2, members=members, initiator=user, status=closed_status
|
||||||
|
)
|
||||||
|
file, job = await make_stored_file(
|
||||||
|
session, entity=members[0], status=FileStatus.ERROR, job_status=RecognitionStatus.timeout
|
||||||
|
)
|
||||||
|
file.consolidation_id = working.consolidation_id
|
||||||
|
job.error_code, job.max_attempts = "TIMEOUT", 3
|
||||||
|
file_id, user_id, closed_id = file.id, user.id, closed.id
|
||||||
|
|
||||||
|
async def retry():
|
||||||
|
async with committed_sessionmaker() as session, session.begin():
|
||||||
|
audit = RequestAudit(AuditService(session), user_id, None, None)
|
||||||
|
return await FileRetryService(session, audit, VisibilityScope(None)).retry(file_id)
|
||||||
|
|
||||||
|
async with committed_sessionmaker() as holder, holder.begin():
|
||||||
|
await holder.execute(select(ConsolidationVersion).where(ConsolidationVersion.id == closed_id).with_for_update())
|
||||||
|
card = await asyncio.wait_for(retry(), timeout=5)
|
||||||
|
assert card.status == FileStatus.UPLOADED
|
||||||
|
|||||||
@ -275,3 +275,12 @@ def test_version_member_declares_type_and_osv_accounts(schema: dict[str, Any]) -
|
|||||||
"""front-api-gaps D-3: участник периметра — тип ЮЛ и счета ОСВ к периоду версии."""
|
"""front-api-gaps D-3: участник периметра — тип ЮЛ и счета ОСВ к периоду версии."""
|
||||||
member = schema["components"]["schemas"]["MemberOut"]
|
member = schema["components"]["schemas"]["MemberOut"]
|
||||||
assert {"entity_type", "osv_accounts", "checklist"} <= set(member["required"])
|
assert {"entity_type", "osv_accounts", "checklist"} <= set(member["required"])
|
||||||
|
|
||||||
|
|
||||||
|
def test_retry_routes_declare_different_conflicts(schema: dict[str, Any]) -> None:
|
||||||
|
single = schema["paths"]["/api/v1/files/{file_id}/retry"]["post"]["responses"]["409"]["description"]
|
||||||
|
batch = schema["paths"]["/api/v1/consolidations/versions/{version_id}/files/retry"]["post"]["responses"]["409"][
|
||||||
|
"description"
|
||||||
|
]
|
||||||
|
assert "FILE_NOT_RETRYABLE" in single and "MAX_ATTEMPTS_REACHED" in single
|
||||||
|
assert batch == "VERSION_NOT_EDITABLE"
|
||||||
|
|||||||
@ -38,8 +38,9 @@ DocumentService умеет confirm; привязки централизован
|
|||||||
|
|
||||||
### D2. Новая попытка вместо обновления прежнего job
|
### D2. Новая попытка вместо обновления прежнего job
|
||||||
|
|
||||||
Создать новый UUID job, queued, attempt + 1; перенести только file_id, pipeline, max_attempts и настройки
|
Создать новый UUID job, queued, attempt + 1; перенести file_id, pipeline, max_attempts.
|
||||||
контракта/маршрута, необходимые публикатору. Ошибки, метрики, result_s3_object_id, service metadata, message ids
|
schema_version брать из CONTRACT_VERSION, request_subject — из текущих настроек NATS, как при загрузке;
|
||||||
|
версию контракта и subject прежнего задания не копировать. Ошибки, метрики, result_s3_object_id, service metadata, message ids
|
||||||
и времена старой попытки не копируются. requested_at — сейчас, reply_deadline формируется публикатором.
|
и времена старой попытки не копируются. requested_at — сейчас, reply_deadline формируется публикатором.
|
||||||
Переключить current_recognition_job_id и вернуть файл из ERROR в UPLOADED. Прежний job неизменен.
|
Переключить current_recognition_job_id и вернуть файл из ERROR в UPLOADED. Прежний job неизменен.
|
||||||
Это соответствует уточнённой задаче и не требует изменения дедупа парсера по старому job_id (OQ-73).
|
Это соответствует уточнённой задаче и не требует изменения дедупа парсера по старому job_id (OQ-73).
|
||||||
@ -47,7 +48,9 @@ DocumentService умеет confirm; привязки централизован
|
|||||||
|
|
||||||
### D3. Пакет, частичные отказы и версии
|
### D3. Пакет, частичные отказы и версии
|
||||||
|
|
||||||
Выбрать файлы выбранной консолидации по stored_file.consolidation_id, с ЮЛ из периметра версии и policy ЮЛ.
|
Отбор repositories/files.py::retry_candidates выбирает только ERROR-файлы выбранной консолидации
|
||||||
|
по stored_file.consolidation_id, с ЮЛ из периметра версии и policy ЮЛ, до любых блокировок.
|
||||||
|
Остальные статусы не блокируются и не попадают в skipped; после блокировки статус ERROR перепроверяется.
|
||||||
Файл без документов не отбирается через consolidation_document. Дедуп по file_id.
|
Файл без документов не отбирается через consolidation_document. Дедуп по file_id.
|
||||||
Согласованный пользователем общий набор у нескольких версий сохраняется; повтор не создаёт версионной копии файла.
|
Согласованный пользователем общий набор у нескольких версий сохраняется; повтор не создаёт версионной копии файла.
|
||||||
|
|
||||||
@ -63,11 +66,15 @@ DocumentService умеет confirm; привязки централизован
|
|||||||
### D4. Блокировки и гонки
|
### D4. Блокировки и гонки
|
||||||
|
|
||||||
Retry следует порядку работающего обмена: текущее задание FOR UPDATE → файл FOR UPDATE → версии по id.
|
Retry следует порядку работающего обмена: текущее задание FOR UPDATE → файл FOR UPDATE → версии по id.
|
||||||
|
В одиночном повторе только рабочие версии берутся FOR SHARE: статус и периметр защищены от изменений,
|
||||||
|
обычное чтение не блокируется, VERIFIED/CANCELLED вообще не блокируются.
|
||||||
После блокировки файла перечитать current_recognition_job_id; если изменился, одиночный запрос получает
|
После блокировки файла перечитать current_recognition_job_id; если изменился, одиночный запрос получает
|
||||||
FILE_NOT_RETRYABLE, пакет пропускает файл. Не захватывать новое текущее задание, уже удерживая файл:
|
FILE_NOT_RETRYABLE, пакет пропускает файл. Не захватывать новое текущее задание, уже удерживая файл:
|
||||||
это инвертирует порядок обмена. Лимит и отсутствие документов проверять заново под блокировкой.
|
это инвертирует порядок обмена. Лимит и отсутствие документов проверять заново под блокировкой.
|
||||||
Для пакета сначала блокировать все отобранные прежние jobs по id, затем файлы по id, затем версии по id;
|
Для пакета сначала блокировать все отобранные прежние jobs по id, затем файлы по id, затем версии по id;
|
||||||
после захвата версий перечитать статус и состав периметра. Не начинать пакет с FOR UPDATE версии.
|
после захвата версий перечитать статус и состав периметра. Не начинать пакет с FOR UPDATE версии.
|
||||||
|
OpenAPI одиночного повтора описывает FILE_NOT_RETRYABLE/MAX_ATTEMPTS_REACHED/VERSION_NOT_EDITABLE;
|
||||||
|
для пакетного 409 только VERSION_NOT_EDITABLE, остальные причины возвращаются в skipped.
|
||||||
|
|
||||||
Для PATCH — файл → документ FOR NO KEY UPDATE → существующая advisory-блокировка привязок ГК → версии
|
Для PATCH — файл → документ FOR NO KEY UPDATE → существующая advisory-блокировка привязок ГК → версии
|
||||||
по id, как при confirm/delete. Проверить связанные статусы под блокировками и не использовать
|
по id, как при confirm/delete. Проверить связанные статусы под блокировками и не использовать
|
||||||
|
|||||||
@ -18,11 +18,11 @@
|
|||||||
- **THEN** 409 MAX_ATTEMPTS_REACHED, состояние не меняется
|
- **THEN** 409 MAX_ATTEMPTS_REACHED, состояние не меняется
|
||||||
|
|
||||||
### Requirement: Пакет повторной обработки версии
|
### Requirement: Пакет повторной обработки версии
|
||||||
`POST /api/v1/consolidations/versions/{version_id}/files/retry` SHALL рассматривать видимые файлы в статусе `ERROR` с
|
`POST /api/v1/consolidations/versions/{version_id}/files/retry` SHALL выбирать видимые ERROR-файлы
|
||||||
consolidation_id консолидации версии и ЮЛ из периметра версии; файлы других статусов в пакет не входят и не блокируются.
|
консолидации по периметру версии. Другие статусы не входят в пакет и не блокируются.
|
||||||
Для каждого допустимого файла SHALL создаваться новая попытка. Успех — 202 с accepted[{file_id, job_id, attempt}] и
|
Ответ — 202 с accepted[{file_id, job_id, attempt}] и skipped[{file_id, code}]. В skipped SHALL входить
|
||||||
skipped[{file_id, code}], где skipped — рассмотренные файлы, которые повторить нельзя (`FILE_NOT_RETRYABLE`,
|
только ERROR-файлы, которые нельзя повторить (FILE_NOT_RETRYABLE, MAX_ATTEMPTS_REACHED).
|
||||||
`MAX_ATTEMPTS_REACHED`). Файлы без контекста консолидации MUST NOT включаться по одному лишь совпадению ЮЛ.
|
Файлы без контекста консолидации MUST NOT включаться по совпадению ЮЛ.
|
||||||
|
|
||||||
#### Scenario: Смешанный пакет
|
#### Scenario: Смешанный пакет
|
||||||
- **WHEN** у версии есть файл timeout/TIMEOUT, OCR, успешный файл и файл с исчерпанным лимитом
|
- **WHEN** у версии есть файл timeout/TIMEOUT, OCR, успешный файл и файл с исчерпанным лимитом
|
||||||
|
|||||||
@ -79,4 +79,11 @@
|
|||||||
- [x] 7.1 Ревьюер: `api/.env.test` — порт PostgreSQL 5432 по compose (свой порт — переменной `OPENBAO__DATABASE__DATABASE_URL`).
|
- [x] 7.1 Ревьюер: `api/.env.test` — порт PostgreSQL 5432 по compose (свой порт — переменной `OPENBAO__DATABASE__DATABASE_URL`).
|
||||||
- [x] 7.2 Ревьюер: дельта data-model переименована на v5.21, `openspec/config.yaml` — DBML v5.21, README/CLAUDE.md — миграции 0008/0009.
|
- [x] 7.2 Ревьюер: дельта data-model переименована на v5.21, `openspec/config.yaml` — DBML v5.21, README/CLAUDE.md — миграции 0008/0009.
|
||||||
- [x] 7.3 Ревьюер: синтетические тесты PATCH шапки ОСВ без `fixtures`, смешанный пакет и NO_ACK на пределе.
|
- [x] 7.3 Ревьюер: синтетические тесты PATCH шапки ОСВ без `fixtures`, смешанный пакет и NO_ACK на пределе.
|
||||||
- [ ] 7.4 Автор: замечания комментария ревью (пакет — только файлы в `ERROR`, блокировки версий, `CONTRACT_VERSION`, репозиторий, OpenAPI).
|
- [x] 7.4 Автор: замечания комментария ревью (пакет — только файлы в `ERROR`, блокировки версий, `CONTRACT_VERSION`, репозиторий, OpenAPI).
|
||||||
|
|
||||||
|
Раунд 1: все шесть замечаний к коду исправлены. Пакет отбирается репозиторием только по ERROR до блокировок,
|
||||||
|
статус перепроверяется под блокировкой; одиночный повтор берёт FOR SHARE только рабочих версий.
|
||||||
|
Новая попытка использует CONTRACT_VERSION и subject из SettingsDep. OpenAPI-конфликты разделены;
|
||||||
|
проверка пары status/error_code сохранена, вопросительный комментарий удалён.
|
||||||
|
Проверки: 101 passed (file_retry, синтетическая шапка ОСВ, OpenAPI), make lint,
|
||||||
|
openspec validate --strict, git diff --check — успешно. .env.test не менялся.
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user