fix(mapping): категории вместо текста исключений в mapping_error, вставка строк по level

M-1 (= T7.6): metrics.mapping_error виден пользователю (JobBrief.mapping_error) —
ошибка схемы даёт «результат не соответствует схеме recognition-result: ошибок N,
первая: <loc> <type>» без input_value и ссылок pydantic; исчерпавшая попытки
ошибка БД/S3 — «маппинг не удался: ошибка БД/S3 (<ExcType>), попыток N» без
[SQL: …] и параметров. Полный текст исключения — только в логе.

T7.2: черновики osv_entry сортируются устойчиво по level перед вставкой пачками —
родитель раньше потомка на любой границе пачки, независимо от порядка строк
результата.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2
This commit is contained in:
Andrey Epifancev 2026-10-06 00:33:49 +04:00
parent 24d556e41e
commit 583edf2a8d
2 changed files with 93 additions and 7 deletions

View File

@ -17,6 +17,8 @@ PROCESSING` у файла, чьё ТЕКУЩЕЕ задание (`current_recogn
Ошибки (D-2): неповторяемые (`MappingFatal`: ключ вне папки, объекта нет, JSON не проходит схему, нет документов ОСВ) Ошибки (D-2): неповторяемые (`MappingFatal`: ключ вне папки, объекта нет, JSON не проходит схему, нет документов ОСВ)
— сразу файл `ERROR` и `metrics.mapping_error`; прочие — откат транзакции маппинга, в отдельной транзакции — сразу файл `ERROR` и `metrics.mapping_error`; прочие — откат транзакции маппинга, в отдельной транзакции
`metrics.mapping_attempts + 1`, на `MAPPING_MAX_ATTEMPTS` — `ERROR`; следующая попытка — после паузы цикла (R-15). `metrics.mapping_attempts + 1`, на `MAPPING_MAX_ATTEMPTS` — `ERROR`; следующая попытка — после паузы цикла (R-15).
`mapping_error` виден пользователю: в нём категория (схема — число ошибок и путь первой; БД/S3 — тип исключения и число
попыток), без текста исключений pydantic/SQLAlchemy; полный текст — только в логе (M-1).
Статус файла — через `services/file_status.py` (только текущее задание). `audit_event` не пишется: обмен и маппинг Статус файла — через `services/file_status.py` (только текущее задание). `audit_event` не пишется: обмен и маппинг
не аудируются (ASSUMPTION(OQ-55)); `FILE_REPLACE` автозамены — системное событие (`user_id = NULL`). не аудируются (ASSUMPTION(OQ-55)); `FILE_REPLACE` автозамены — системное событие (`user_id = NULL`).
""" """
@ -175,6 +177,16 @@ def validate_result_key(candidate: Candidate, storage_bucket: str) -> None:
# --- шаг 2: чтение результата --------------------------------------------------------------------------------------- # --- шаг 2: чтение результата ---------------------------------------------------------------------------------------
def _schema_error_summary(exc: ValidationError) -> str:
"""M-1: для `metrics.mapping_error` (видно пользователю) — число ошибок, путь и тип первой; без `input_value` и
ссылок pydantic (полный текст — в логе через `__cause__` у `MappingFatal`)."""
errors = exc.errors(include_url=False, include_input=False, include_context=False)
first = errors[0] if errors else None
where = ".".join(map(str, first["loc"])) if first and first["loc"] else "<корень>"
kind = first["type"] if first else "?"
return f"результат не соответствует схеме recognition-result: ошибок {exc.error_count()}, первая: {where} {kind}"
def _osv_documents(result: RecognitionResult) -> list[OsvDocument]: def _osv_documents(result: RecognitionResult) -> list[OsvDocument]:
return [document for document in result.documents if isinstance(document, OsvDocument)] return [document for document in result.documents if isinstance(document, OsvDocument)]
@ -188,7 +200,7 @@ async def _load_result(storage: ObjectStorage, candidate: Candidate) -> Recognit
try: try:
result = RecognitionResult.model_validate_json(content) result = RecognitionResult.model_validate_json(content)
except ValidationError as exc: except ValidationError as exc:
raise MappingFatal(f"файл результата не соответствует схеме recognition-result: {exc}") from exc raise MappingFatal(_schema_error_summary(exc)) from exc
if result.job_id != candidate.job_id or result.file_id != candidate.file_id: if result.job_id != candidate.job_id or result.file_id != candidate.file_id:
raise MappingFatal( raise MappingFatal(
f"файл результата относится к другому заданию: job_id={result.job_id}, file_id={result.file_id}" f"файл результата относится к другому заданию: job_id={result.job_id}, file_id={result.file_id}"
@ -405,6 +417,9 @@ async def map_file( # noqa: PLR0913 - все входы маппинга фай
session.add_all(documents) session.add_all(documents)
await session.flush() await session.flush()
# T7.2: родитель раньше потомка на любой границе пачки (self-FK `parent_entry_id`) — не полагаемся на порядок строк
# результата; сортировка устойчивая, внутри уровня порядок строк сохраняется
entries.sort(key=lambda values: values["level"])
for start in range(0, len(entries), ENTRY_BATCH_SIZE): for start in range(0, len(entries), ENTRY_BATCH_SIZE):
await session.execute(insert(OsvEntry).values(entries[start : start + ENTRY_BATCH_SIZE])) await session.execute(insert(OsvEntry).values(entries[start : start + ENTRY_BATCH_SIZE]))
@ -558,15 +573,18 @@ async def _lock_file(session: AsyncSession, file_id: uuid.UUID) -> StoredFile |
return (await session.execute(stmt)).scalar_one_or_none() return (await session.execute(stmt)).scalar_one_or_none()
async def _fail(session: AsyncSession, candidate: Candidate, message: str, now: datetime) -> None: async def _fail(
"""Неповторяемая ошибка: `metrics.mapping_error` и файл текущего задания → `ERROR` (транзакция вызывающего).""" session: AsyncSession, candidate: Candidate, message: str, now: datetime, *, cause: BaseException | None = None
) -> None:
"""Неповторяемая ошибка: `metrics.mapping_error` и файл текущего задания → `ERROR` (транзакция вызывающего).
`cause` — исходное исключение (например, `ValidationError`): его полный текст пишется только в лог (M-1)."""
job = await _lock_job(session, candidate.job_id) job = await _lock_job(session, candidate.job_id)
if job is None: if job is None:
return return
job.metrics = {**_metrics(job), MAPPING_ERROR_KEY: _short(message)} job.metrics = {**_metrics(job), MAPPING_ERROR_KEY: _short(message)}
await session.flush() await session.flush()
changed = await set_file_status_for_job(session, job, FileStatus.ERROR, now=now) changed = await set_file_status_for_job(session, job, FileStatus.ERROR, now=now)
logger.error( logger.opt(exception=cause).error(
"Маппинг результата невозможен: файл переведён в ERROR. error_category=mapping_fatal, job_id={job_id}, " "Маппинг результата невозможен: файл переведён в ERROR. error_category=mapping_fatal, job_id={job_id}, "
"file_id={file_id}, file_changed={changed}, error={error}", "file_id={file_id}, file_changed={changed}, error={error}",
job_id=candidate.job_id, job_id=candidate.job_id,
@ -595,8 +613,9 @@ async def _register_failure(
metrics[MAPPING_ATTEMPTS_KEY] = attempts metrics[MAPPING_ATTEMPTS_KEY] = attempts
exhausted = attempts >= max_attempts exhausted = attempts >= max_attempts
if exhausted: if exhausted:
# M-1: только категория и тип исключения — текст SQLAlchemy/asyncpg ([SQL: …], параметры) остаётся в логе
metrics[MAPPING_ERROR_KEY] = _short( metrics[MAPPING_ERROR_KEY] = _short(
f"маппинг не удался после {attempts} попыток: {type(exc).__name__}: {exc}" f"маппинг не удался: ошибка БД/S3 ({type(exc).__name__}), попыток {attempts}"
) )
job.metrics = metrics job.metrics = metrics
await session.flush() await session.flush()
@ -674,7 +693,7 @@ async def process_once(
await _map_candidate(session_factory, candidate, result, clock) await _map_candidate(session_factory, candidate, result, clock)
except MappingFatal as exc: except MappingFatal as exc:
async with session_factory() as session, session.begin(): async with session_factory() as session, session.begin():
await _fail(session, candidate, str(exc), clock()) await _fail(session, candidate, str(exc), clock(), cause=exc.__cause__)
except Exception as exc: # сбой S3 или БД: транзакция маппинга откачена, повтор после паузы цикла (R-15) except Exception as exc: # сбой S3 или БД: транзакция маппинга откачена, повтор после паузы цикла (R-15)
return await _register_failure( return await _register_failure(
session_factory, candidate, exc, max_attempts=settings.MAPPING_MAX_ATTEMPTS, now=clock() session_factory, candidate, exc, max_attempts=settings.MAPPING_MAX_ATTEMPTS, now=clock()

View File

@ -21,6 +21,7 @@ from typing import Any
import pytest import pytest
from loguru import logger from loguru import logger
from sqlalchemy import func, select from sqlalchemy import func, select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
from src.contract import ack_msg_id, result_msg_id from src.contract import ack_msg_id, result_msg_id
@ -378,12 +379,78 @@ async def test_mapping_failure_retries_then_error(
assert await process_once(maker, storage, _settings()) is True assert await process_once(maker, storage, _settings()) is True
job = await _job(maker, job_id) job = await _job(maker, job_id)
assert job.metrics[MAPPING_ATTEMPTS_KEY] == 3 # noqa: PLR2004 - MAPPING_MAX_ATTEMPTS по умолчанию assert job.metrics[MAPPING_ATTEMPTS_KEY] == 3 # noqa: PLR2004 - MAPPING_MAX_ATTEMPTS по умолчанию
assert "подставная ошибка БД" in job.metrics[MAPPING_ERROR_KEY] # M-1: пользователю — категория и число попыток, текст исключения остаётся только в логе
assert job.metrics[MAPPING_ERROR_KEY] == "маппинг не удался: ошибка БД/S3 (RuntimeError), попыток 3"
assert (await _file(maker, file_id)).status is FileStatus.ERROR assert (await _file(maker, file_id)).status is FileStatus.ERROR
assert len(calls) == 3 # noqa: PLR2004 - три попытки assert len(calls) == 3 # noqa: PLR2004 - три попытки
assert await process_once(maker, storage, _settings()) is False assert await process_once(maker, storage, _settings()) is False
_INTERNALS = ("[SQL:", "input_value", "http", "sqlalche.me", "parameters")
async def test_schema_error_hides_input_values(committed_sessionmaker: async_sessionmaker[AsyncSession]):
"""M-1: ошибка схемы результата — в `mapping_error` число ошибок и путь/тип первой, без `input_value` и ссылок
pydantic (полный текст — только в логе)."""
maker, storage = committed_sessionmaker, FakeStorage()
result = _minimal()
_osv(result)["rows"][1]["opening_debit"] = "секрет http://example.invalid"
file_id, job_id, _ = await _prepare(maker, storage, result)
assert await process_once(maker, storage, _settings()) is True
assert (await _file(maker, file_id)).status is FileStatus.ERROR
message = (await _job(maker, job_id)).metrics[MAPPING_ERROR_KEY]
assert message.startswith("результат не соответствует схеме recognition-result: ошибок ")
assert "первая: documents.0." in message
assert "opening_debit" in message
assert "секрет" not in message
assert not [marker for marker in _INTERNALS if marker in message]
async def test_retryable_db_error_hides_sql(
committed_sessionmaker: async_sessionmaker[AsyncSession], monkeypatch: pytest.MonkeyPatch
):
"""M-1: исчерпавшая попытки ошибка БД — в `mapping_error` категория, тип исключения и число попыток, без
`[SQL: …]`, параметров и ссылок SQLAlchemy."""
maker, storage = committed_sessionmaker, FakeStorage()
file_id, job_id, _ = await _prepare(maker, storage, _minimal())
async def failing_map_file(session: AsyncSession, **kwargs: Any) -> list[str]:
raise IntegrityError("INSERT INTO osv_entry (id) VALUES ($1)", {"id": "секрет"}, Exception("fk violation"))
monkeypatch.setattr(rm, "map_file", failing_map_file)
settings = _settings().model_copy(update={"MAPPING_MAX_ATTEMPTS": 1})
assert await process_once(maker, storage, settings) is True
assert (await _file(maker, file_id)).status is FileStatus.ERROR
message = (await _job(maker, job_id)).metrics[MAPPING_ERROR_KEY]
assert message == "маппинг не удался: ошибка БД/S3 (IntegrityError), попыток 1"
assert not [marker for marker in _INTERNALS if marker in message]
async def test_child_before_parent_across_batches(
committed_sessionmaker: async_sessionmaker[AsyncSession], monkeypatch: pytest.MonkeyPatch
):
"""T7.2: строки вставляются родитель раньше потомка независимо от порядка строк в результате — потомок перед
родителем на границе пачки не нарушает self-FK `parent_entry_id`."""
maker, storage = committed_sessionmaker, FakeStorage()
result = _minimal()
rows = _osv(result)["rows"]
rows.reverse() # потомок (level 1) раньше корня (level 0)
monkeypatch.setattr(rm, "ENTRY_BATCH_SIZE", 1) # каждая строка — отдельная пачка
file_id, _, _ = await _prepare(maker, storage, result)
assert await process_once(maker, storage, _settings()) is True
assert (await _file(maker, file_id)).status is FileStatus.PROCESSED
[document] = await _documents(maker, file_id)
entries = await _entries(maker, document.id)
root, child = entries
assert child.parent_entry_id == root.id
async def test_repeated_mapping_is_idempotent(committed_sessionmaker: async_sessionmaker[AsyncSession]): async def test_repeated_mapping_is_idempotent(committed_sessionmaker: async_sessionmaker[AsyncSession]):
"""«Повторный маппинг идемпотентен»: остатки прерванной вставки (документ и половина строк) удаляются, у файла """«Повторный маппинг идемпотентен»: остатки прерванной вставки (документ и половина строк) удаляются, у файла
ровно один набор документов и строк.""" ровно один набор документов и строк."""