fix(consolidation): ревью Task 7 — тест надмножества блокировок хука маппинга, устойчивость загрузки к ошибке перехода

T7.0: двухсессионный тест маппинг ∥ перевод версии в DOCUMENTS_PROCESSING (ловит мутант
statuses={DOCUMENTS_PROCESSING}). T7.1 (R-10, design №43): ошибка перехода после загрузки логируется,
принятые файлы и 201 сохраняются. T7.3: лог пропуска без uploaded_by только при наличии NEEDS_CHECK.
T7.4: TransactionFactory импортируется из session_audit. T7.2/T7.5: дополнена строка design №39.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2
This commit is contained in:
Andrey Epifancev 2026-10-06 05:37:39 +04:00
parent 1908288613
commit 4cc08cc3de
5 changed files with 107 additions and 36 deletions

View File

@ -279,15 +279,15 @@ async def on_documents_mapped(
заблокированы, автозамена прежних версий уже взяла advisory ЮЛ → старые файлы → advisory ГК → версии): порядок заблокированы, автозамена прежних версий уже взяла advisory ЮЛ → старые файлы → advisory ГК → версии): порядок
«задание → файл → документы → версии» не нарушается, версии блокируются последними. «задание → файл → документы → версии» не нарушается, версии блокируются последними.
""" """
if file.uploaded_by is None: needs_check = [document for document in documents if document.status is DocumentStatus.NEEDS_CHECK]
if not needs_check:
return []
if file.uploaded_by is None: # лог — только когда переход мог состояться (есть NEEDS_CHECK)
logger.info( logger.info(
"Переход версий по маппингу пропущен: у файла нет uploaded_by (changed_by обязателен). file_id={file_id}", "Переход версий по маппингу пропущен: у файла нет uploaded_by (changed_by обязателен). file_id={file_id}",
file_id=file.id, file_id=file.id,
) )
return [] return []
needs_check = [document for document in documents if document.status is DocumentStatus.NEEDS_CHECK]
if not needs_check:
return []
# цикл импортов: consolidation_status → consolidations → consolidation_documents (design №36) # цикл импортов: consolidation_status → consolidations → consolidation_documents (design №36)
from src.services.consolidation_status import transition # noqa: PLC0415 from src.services.consolidation_status import transition # noqa: PLC0415

View File

@ -18,8 +18,6 @@ audit-trail «События файлов и документов»; design D-4)
import hashlib import hashlib
import uuid import uuid
from collections.abc import Callable
from contextlib import AbstractAsyncContextManager
from dataclasses import dataclass from dataclasses import dataclass
from loguru import logger from loguru import logger
@ -58,6 +56,7 @@ from src.services.consolidation_guard import VERSION_NOT_FOUND
from src.services.documents import file_out from src.services.documents import file_out
from src.services.legal_entities import LEGAL_ENTITY_NOT_FOUND from src.services.legal_entities import LEGAL_ENTITY_NOT_FOUND
from src.services.result_mapping import RESULTS_PREFIX from src.services.result_mapping import RESULTS_PREFIX
from src.services.session_audit import TransactionFactory
OBJECT_TYPE = "stored_file" OBJECT_TYPE = "stored_file"
UPLOADS_PREFIX = "uploads" UPLOADS_PREFIX = "uploads"
@ -69,9 +68,6 @@ FILE_NOT_FOUND = "файл не найден" # одинаково для чу
FILE_HAS_CONFIRMED_DOCUMENTS = "FILE_HAS_CONFIRMED_DOCUMENTS" FILE_HAS_CONFIRMED_DOCUMENTS = "FILE_HAS_CONFIRMED_DOCUMENTS"
FILE_SUPERSEDES_OTHERS = "FILE_SUPERSEDES_OTHERS" FILE_SUPERSEDES_OTHERS = "FILE_SUPERSEDES_OTHERS"
#: фабрика отдельной короткой транзакции (в проде — `src.db.db.session_transaction`): commit на выходе
TransactionFactory = Callable[[], AbstractAsyncContextManager[AsyncSession]]
class StorageUnavailableError(DomainError): class StorageUnavailableError(DomainError):
"""503 `STORAGE_UNAVAILABLE`: S3 не подключено (dev без S3) или не приняло объект.""" """503 `STORAGE_UNAVAILABLE`: S3 не подключено (dev без S3) или не приняло объект."""
@ -168,7 +164,11 @@ class PrimaryFilesService:
В ОТДЕЛЬНОЙ транзакции (`session_transaction`, как аудит входа): `lock_version` (`FOR UPDATE`) → статус В ОТДЕЛЬНОЙ транзакции (`session_transaction`, как аудит входа): `lock_version` (`FOR UPDATE`) → статус
перечитывается — если уже не `INITIATED` (параллельный переход, аннулирование), ничего не меняется. Версия перечитывается — если уже не `INITIATED` (параллельный переход, аннулирование), ничего не меняется. Версия
блокируется на короткое время и вне транзакции запроса, чтобы загрузка не держала её на время приёма файлов блокируется на короткое время и вне транзакции запроса, чтобы загрузка не держала её на время приёма файлов
(D-9). Транзакция запроса к этому моменту версию не трогала, взаимных ожиданий нет.""" (D-9). Транзакция запроса к этому моменту версию не трогала, взаимных ожиданий нет.
Приём файлов важнее побочного статуса (R-10, design №43): любая ошибка перехода (`Exception`) логируется и
НЕ меняет ответ — принятые файлы остаются, версия остаётся `INITIATED` до следующей загрузки или
подтверждения. `BaseException` (`asyncio.CancelledError`) не перехватывается."""
# импорт внутри функции — как в consolidation_documents (design №36): модули консолидации импортируют друг друга # импорт внутри функции — как в consolidation_documents (design №36): модули консолидации импортируют друг друга
from src.services.consolidation_status import transition # noqa: PLC0415 from src.services.consolidation_status import transition # noqa: PLC0415
@ -177,6 +177,7 @@ class PrimaryFilesService:
from src.db.db import session_transaction # noqa: PLC0415 from src.db.db import session_transaction # noqa: PLC0415
factory = session_transaction factory = session_transaction
try:
async with factory() as session: async with factory() as session:
version = await consolidations_repo.lock_version(session, version_id) version = await consolidations_repo.lock_version(session, version_id)
if version is None or version.status is not ConsolidationStatus.INITIATED: if version is None or version.status is not ConsolidationStatus.INITIATED:
@ -192,6 +193,12 @@ class PrimaryFilesService:
user_agent=self._audit.user_agent, user_agent=self._audit.user_agent,
now=utc_now(), now=utc_now(),
) )
except Exception:
logger.exception(
"Не удалось перевести версию в DOCUMENTS_PROCESSING после загрузки, файлы приняты. "
"error_category=version_transition_error, version_id={version_id}",
version_id=version_id,
)
def require_storage(self) -> ObjectStorage: def require_storage(self) -> ObjectStorage:
"""S3 приложения; без него загрузка невозможна — 503 `STORAGE_UNAVAILABLE`.""" """S3 приложения; без него загрузка невозможна — 503 `STORAGE_UNAVAILABLE`."""

View File

@ -17,6 +17,7 @@ from typing import Any
import httpx import httpx
import pytest import pytest
from fastapi import FastAPI from fastapi import FastAPI
from loguru import logger
from sqlalchemy import func, select, update from sqlalchemy import func, select, update
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
@ -736,3 +737,31 @@ async def test_upload_without_version_does_not_touch_versions(
assert response.status_code == HTTP_CREATED, response.text assert response.status_code == HTTP_CREATED, response.text
assert await _version_state(seeded, version.id) is ConsolidationStatus.INITIATED assert await _version_state(seeded, version.id) is ConsolidationStatus.INITIATED
assert len(storage.objects) == 1 assert len(storage.objects) == 1
@pytest.mark.integration
async def test_upload_keeps_accepted_files_when_version_transition_fails(
auth_client: AuthClientFactory, seeded: AsyncSession, storage: FakeStorage, monkeypatch: pytest.MonkeyPatch
) -> None:
"""R-10 (design №43): приём файлов важнее побочного статуса. Переход версии падает исключением — ответ 201, принятый
файл сохранён, версия остаётся `INITIATED`, ошибка — в логе."""
async def broken_transition(*args: Any, **kwargs: Any) -> None:
raise RuntimeError("переход недоступен")
monkeypatch.setattr("src.services.consolidation_status.transition", broken_transition)
version, (member, *_), _ = await _version(seeded)
client = await auth_client(KA_RF)
messages: list[str] = []
sink = logger.add(lambda message: messages.append(str(message)), level="ERROR")
try:
response = await _post_in_version(client, member.id, version.id, ("osv.xls", _xls("transition-fails")))
finally:
logger.remove(sink)
assert response.status_code == HTTP_CREATED, response.text
[accepted] = response.json()["accepted"]
assert (await seeded.get(StoredFile, uuid.UUID(accepted["file_id"]))) is not None
assert len(storage.objects) == 1
assert await _version_state(seeded, version.id) is ConsolidationStatus.INITIATED
assert any(str(version.id) in message and "version_transition_error" in message for message in messages)

View File

@ -936,6 +936,18 @@ async def test_on_documents_mapped_without_uploader_logs_and_skips(seeded: Async
assert any(str(file.id) in message and "uploaded_by" in message for message in messages) assert any(str(file.id) in message and "uploaded_by" in message for message in messages)
async def _mapped_in_own_transaction(maker: async_sessionmaker[AsyncSession], data: dict[str, Any]) -> list[uuid.UUID]:
"""Хук маппинга файла `data["file_id"]` в своей транзакции (как цикл маппинга: файл и документы уже есть)."""
async with maker() as session, session.begin():
file = await session.get(StoredFile, data["file_id"])
document = await session.get(Document, data["document_id"])
assert file is not None
assert document is not None
return await consolidation_documents.on_documents_mapped(
session, file=file, documents=[document], now=utc_now()
)
@pytest.mark.integration @pytest.mark.integration
async def test_on_documents_mapped_waits_for_member_edit_and_sees_new_member( async def test_on_documents_mapped_waits_for_member_edit_and_sees_new_member(
committed_sessionmaker: async_sessionmaker[AsyncSession], committed_sessionmaker: async_sessionmaker[AsyncSession],
@ -952,21 +964,43 @@ async def test_on_documents_mapped_waits_for_member_edit_and_sees_new_member(
assert file is not None assert file is not None
file.uploaded_by = data["user_id"] file.uploaded_by = data["user_id"]
async def mapped() -> list[uuid.UUID]:
async with maker() as session, session.begin():
file = await session.get(StoredFile, data["file_id"])
document = await session.get(Document, data["document_id"])
assert file is not None
assert document is not None
return await consolidation_documents.on_documents_mapped(
session, file=file, documents=[document], now=utc_now()
)
async with maker() as editor, editor.begin(): async with maker() as editor, editor.begin():
assert await repo.lock_version(editor, data["version_id"]) is not None assert await repo.lock_version(editor, data["version_id"]) is not None
editor.add(ConsolidationMember(consolidation_version_id=data["version_id"], legal_entity_id=data["owner_id"])) editor.add(ConsolidationMember(consolidation_version_id=data["version_id"], legal_entity_id=data["owner_id"]))
await editor.flush() await editor.flush()
hook = asyncio.create_task(mapped()) hook = asyncio.create_task(_mapped_in_own_transaction(maker, data))
await _wait_for_lock_waiter(maker)
assert not hook.done()
moved = await asyncio.wait_for(hook, LOCK_WAIT_SECONDS)
assert moved == [data["version_id"]]
async with maker() as session:
assert await _status(session, data["version_id"]) is ConsolidationStatus.DATA_CORRECTION
@pytest.mark.integration
async def test_on_documents_mapped_waits_for_transition_to_documents_processing(
committed_sessionmaker: async_sessionmaker[AsyncSession],
) -> None:
"""R-2/№40, маппинг ∥ загрузка (`after_upload_to_version`): версия `INITIATED` держится транзакцией, которая
переводит её в `DOCUMENTS_PROCESSING` (не зафиксировано). Хук блокирует надмножество версий «в работе» (а не только
`DOCUMENTS_PROCESSING`), поэтому встаёт в ожидание строки версии и после commit видит `DOCUMENTS_PROCESSING` —
переход в `DATA_CORRECTION` не теряется. Хук, ищущий версии только в `DOCUMENTS_PROCESSING`, снимок `INITIATED`
пропустил бы без ожидания и вернул бы `[]`."""
maker = committed_sessionmaker
data = await _committed_setup(maker, members=2, spare=0) # владелец документа — член периметра, версия INITIATED
async with maker() as session, session.begin():
file = await session.get(StoredFile, data["file_id"])
assert file is not None
file.uploaded_by = data["user_id"]
async with maker() as uploader, uploader.begin():
version = await repo.lock_version(uploader, data["version_id"])
assert version is not None
assert version.status is ConsolidationStatus.INITIATED
version.status = ConsolidationStatus.DOCUMENTS_PROCESSING
await uploader.flush()
hook = asyncio.create_task(_mapped_in_own_transaction(maker, data))
await _wait_for_lock_waiter(maker) await _wait_for_lock_waiter(maker)
assert not hook.done() assert not hook.done()
moved = await asyncio.wait_for(hook, LOCK_WAIT_SECONDS) moved = await asyncio.wait_for(hook, LOCK_WAIT_SECONDS)

View File

@ -223,7 +223,8 @@ legal_entity_name, account_code, missing_quarters[]}}`. Фильтры `GET …/
| 36 | 2026-10-06 | Переход версии при подтверждении пишется `transition(...)` с `user_id` подтвердившего, но без IP/user-agent (сигнатура хука `on_document_confirmed(session, audit: AuditService, *, document, user_id, now)` фиксирована планом); `transition` импортируется внутри хука (цикл `consolidation_status → consolidations → consolidation_documents`) | audit-trail требует для автоматического перехода только `user_id` инициатора | в `CONSOLIDATION_STATUS_CHANGE` автоматического перехода нет IP/UA — они есть в соседнем `DOCUMENT_CONFIRM` той же транзакции | | 36 | 2026-10-06 | Переход версии при подтверждении пишется `transition(...)` с `user_id` подтвердившего, но без IP/user-agent (сигнатура хука `on_document_confirmed(session, audit: AuditService, *, document, user_id, now)` фиксирована планом); `transition` импортируется внутри хука (цикл `consolidation_status → consolidations → consolidation_documents`) | audit-trail требует для автоматического перехода только `user_id` инициатора | в `CONSOLIDATION_STATUS_CHANGE` автоматического перехода нет IP/UA — они есть в соседнем `DOCUMENT_CONFIRM` той же транзакции |
| 37 | 2026-10-06 | Отдельная транзакция перехода после загрузки (`after_upload_to_version`) берётся из `app.state.session_audit.transaction` (та же фабрика `session_transaction`, что у аудита входа; без неё — `src.db.db.session_transaction`); `get_primary_files_service` передаёт её в `PrimaryFilesService(…, transaction)` | отдельной зависимости «фабрика транзакций» в приложении нет, а фикстуры тестов уже подставляют фабрику в `SessionAudit`; не меняем `app.py` и `conftest` | если `SessionAudit` когда-либо уйдёт из `app.state`, сервис молча возьмёт боевой `session_transaction` (в тестах это была бы чужая БД) | | 37 | 2026-10-06 | Отдельная транзакция перехода после загрузки (`after_upload_to_version`) берётся из `app.state.session_audit.transaction` (та же фабрика `session_transaction`, что у аудита входа; без неё — `src.db.db.session_transaction`); `get_primary_files_service` передаёт её в `PrimaryFilesService(…, transaction)` | отдельной зависимости «фабрика транзакций» в приложении нет, а фикстуры тестов уже подставляют фабрику в `SessionAudit`; не меняем `app.py` и `conftest` | если `SessionAudit` когда-либо уйдёт из `app.state`, сервис молча возьмёт боевой `session_transaction` (в тестах это была бы чужая БД) |
| 38 | 2026-10-06 | Переход после загрузки пишет `CONSOLIDATION_STATUS_CHANGE` с IP и user-agent запроса (в отличие от хука подтверждения, №36); `transition` вызывается из сервиса загрузки с `RequestAudit`-контекстом, но в своей транзакции и своим `AuditService(session)` | сервис знает IP/UA запроса, а `transition` их принимает; потери информации нет | нет | | 38 | 2026-10-06 | Переход после загрузки пишет `CONSOLIDATION_STATUS_CHANGE` с IP и user-agent запроса (в отличие от хука подтверждения, №36); `transition` вызывается из сервиса загрузки с `RequestAudit`-контекстом, но в своей транзакции и своим `AuditService(session)` | сервис знает IP/UA запроса, а `transition` их принимает; потери информации нет | нет |
| 39 | 2026-10-06 | Версия в статусе `DOCUMENTS_PROCESSING`/`DATA_CORRECTION` при загрузке не меняется, `after_upload_to_version` — no-op; если между проверкой и переходом версию аннулировали/перевели, переход не выполняется (статус перечитывается под `FOR UPDATE`), загрузка при этом остаётся принятой (201) | проверка до приёма и переход после — две разные транзакции, окно между ними неустранимо без блокировки версии, которую спека запрещает | версия, аннулированная в окне, остаётся аннулированной; файл принят и виден в ЮЛ, но к версии не привязан (привязка — только подтверждением) | | 39 | 2026-10-06 | Версия в статусе `DOCUMENTS_PROCESSING`/`DATA_CORRECTION` при загрузке не меняется, `after_upload_to_version` — no-op; если между проверкой и переходом версию аннулировали/перевели, переход не выполняется (статус перечитывается под `FOR UPDATE`), загрузка при этом остаётся принятой (201) | проверка до приёма и переход после — две разные транзакции, окно между ними неустранимо без блокировки версии, которую спека запрещает | версия, аннулированная в окне, остаётся аннулированной; файл принят и виден в ЮЛ, но к версии не привязан (привязка — только подтверждением). Окно покрывает и удаление ЮЛ из периметра между проверкой и переходом: версия всё равно перейдёт в `DOCUMENTS_PROCESSING` — допустимо. Отдельная транзакция перехода открывает второе подключение к БД, пока запрос держит своё: при множестве параллельных загрузок в контексте версии это давит на пул (по умолчанию 5+10) — пока теоретически |
| 40 | 2026-10-06 | `on_documents_mapped` блокирует через `lock_versions_for_entity` надмножество по `IN_WORK_STATUSES` и фильтрует `DOCUMENTS_PROCESSING` ПОСЛЕ блокировки, а не передаёт `statuses={DOCUMENTS_PROCESSING}`; вызывается в `_map_candidate` ПОСЛЕ `map_file`, то есть после автозамены | при фильтре статуса в блокирующем запросе версия, которую параллельно переводят `INITIATED → DOCUMENTS_PROCESSING`, не ждалась бы; порядок «файл → документы → advisory ГК → версии» сохраняется, автозамена внутри `map_file` берёт advisory ЮЛ → старые файлы → advisory ГК → версии раньше хука; вызов до автозамены держал бы версии и затем ждал старые файлы (встречная блокировка с подтверждением) | блокируется чуть больше версий ГК (все «в работе»), на время короткой транзакции маппинга | | 40 | 2026-10-06 | `on_documents_mapped` блокирует через `lock_versions_for_entity` надмножество по `IN_WORK_STATUSES` и фильтрует `DOCUMENTS_PROCESSING` ПОСЛЕ блокировки, а не передаёт `statuses={DOCUMENTS_PROCESSING}`; вызывается в `_map_candidate` ПОСЛЕ `map_file`, то есть после автозамены | при фильтре статуса в блокирующем запросе версия, которую параллельно переводят `INITIATED → DOCUMENTS_PROCESSING`, не ждалась бы; порядок «файл → документы → advisory ГК → версии» сохраняется, автозамена внутри `map_file` берёт advisory ЮЛ → старые файлы → advisory ГК → версии раньше хука; вызов до автозамены держал бы версии и затем ждал старые файлы (встречная блокировка с подтверждением) | блокируется чуть больше версий ГК (все «в работе»), на время короткой транзакции маппинга |
| 41 | 2026-10-06 | Хук получает документы файла отдельным `SELECT` по `stored_file_id` после `map_file` (возвращаемое значение `map_file` — предупреждения — не менялось) | сигнатура `map_file` и её тесты не трогаются; документы только что записаны в этой же транзакции | один лишний запрос на файл | | 41 | 2026-10-06 | Хук получает документы файла отдельным `SELECT` по `stored_file_id` после `map_file` (возвращаемое значение `map_file` — предупреждения — не менялось) | сигнатура `map_file` и её тесты не трогаются; документы только что записаны в этой же транзакции | один лишний запрос на файл |
| 42 | 2026-10-06 | Переход по маппингу — только если среди документов файла есть `NEEDS_CHECK`, подходящий версии по `fits` (`fits` — D-5: период, год, член); статус файла/документа в других статусах (`RECOGNIZED`) переход не вызывает, привязки не создаются | спека osv-result-mapping | версия с неподходящим по периоду `NEEDS_CHECK` документом остаётся в `DOCUMENTS_PROCESSING` до подтверждения подходящего | | 42 | 2026-10-06 | Переход по маппингу — только если среди документов файла есть `NEEDS_CHECK`, подходящий версии по `fits` (`fits` — D-5: период, год, член); статус файла/документа в других статусах (`RECOGNIZED`) переход не вызывает, привязки не создаются | спека osv-result-mapping | версия с неподходящим по периоду `NEEDS_CHECK` документом остаётся в `DOCUMENTS_PROCESSING` до подтверждения подходящего |
| 43 | 2026-10-06 | R-10: ошибка (`Exception`) отдельной транзакции перехода после загрузки (`after_upload_to_version`) логируется (`logger.exception`, `error_category=version_transition_error`) и не меняет ответ: 201 с принятыми файлами; `BaseException` (`asyncio.CancelledError`) не перехватывается | приём файлов важнее побочного статуса; иначе 500 откатил бы транзакцию запроса, а принятые объекты S3 остались бы сиротами (как в `SessionAudit`, ошибка записи побочного события ответ не меняет) | версия может остаться `INITIATED` после загрузки до следующей загрузки или подтверждения документа; сбой виден только в логе |