Срез 0 [7.5]: M-1 — TIMEOUT при чередующихся ошибках опроса несёт описание последней ошибки (R-39, design #80)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-10-04 16:02:48 +04:00
parent b127c02422
commit 5b16a53a99
4 changed files with 28 additions and 3 deletions

View File

@ -463,10 +463,18 @@ class Worker:
deadline=deadline.isoformat(),
polls=waited.polls,
)
# R-39: при settle > 0 устойчивая ошибка чтения чередуется с пустыми опросами — код остаётся TIMEOUT,
# но ошибку опроса видно в тексте, чтобы конверт не вёл к ИС, когда не читается горячая папка
poll_failure = (
f"; ошибки опроса исходящего каталога: {waited.failures} из {waited.polls}, "
f"последняя: {_describe(waited.last_error)}"
if waited.failures > 0 and waited.last_error is not None
else ""
)
return self._error(
request,
ErrorCode.TIMEOUT,
f"ИС Сканирование не ответила до {deadline.isoformat()} (опросов: {waited.polls}); "
f"ИС Сканирование не ответила до {deadline.isoformat()} (опросов: {waited.polls}{poll_failure}); "
f"входящий файл {name} остаётся в каталоге, повторной отправки нет",
)
return await self._accept_answer(request, waited.answer, context)

View File

@ -606,6 +606,19 @@ async def test_poll_failing_sometimes_then_silence_is_timeout(make_env: EnvFacto
assert_error(body, ErrorCode.TIMEOUT, raw=False)
async def test_poll_error_alternating_with_empty_polls_is_timeout_with_error_description(
make_env: EnvFactory,
) -> None:
"""R-39 (design #80): при settle > 0 устойчивая ошибка чтения чередуется с пустыми опросами (готовность сбрасывает
состояние), `failures != polls` — код остаётся `TIMEOUT`, но текст несёт описание последней ошибки опроса."""
env = await make_env(SCAN_TIMEOUT_SECONDS=0.2)
env.hotfolder.poll_script = [None, PermissionError(13, "STATUS_ACCESS_DENIED"), *([None] * 2)] * 50
body = await env.run(env.request())
assert_error(body, ErrorCode.TIMEOUT, raw=False)
assert "ИС Сканирование не ответила" in body["error"]["message"]
assert "STATUS_ACCESS_DENIED" in body["error"]["message"]
async def test_missing_s3_object_is_file_read_error(make_env: EnvFactory) -> None:
env = await make_env()
request = env.request(data=None)

View File

@ -158,7 +158,7 @@ class SpyHotFolder:
self.polls = 0
self.poll_errors: list[BaseException] = []
self.poll_always: BaseException | None = None
self.poll_script: list[list[AnswerFile] | None] = []
self.poll_script: list[list[AnswerFile] | BaseException | None] = []
def put(self, name: str, data: bytes) -> None:
self.inner.put(name, data)
@ -172,7 +172,10 @@ class SpyHotFolder:
if self.poll_errors:
raise self.poll_errors.pop(0)
if self.poll_script:
return self.poll_script.pop(0)
step = self.poll_script.pop(0)
if isinstance(step, BaseException):
raise step
return step
return self.inner.poll(job_id)
def healthy(self) -> bool:

View File

@ -215,3 +215,4 @@ warnings; `aep_5.PDF` → `INVALID_DOCUMENT` с `raw_s3_key`; объект `raw/
| 77 | 2026-10-04 | R-27 (design #77): уборка после сквозной проверки Task 6 (7.5) — `docker compose -f ../docs/dev/docker-compose.yml --project-name arm-ka --profile services rm -sfv` только своих сервисов (`scan-gateway`, `scan-emulator`, `arm-core`, `arm-core-migrate`, одноразовый `minio-init`) и удаление тома `arm-ka_hotfolder` после проверки, что его не использует ни один контейнер, вместо `down -v` из плана; `postgres`/`nats`/`minio` и их тома не тронуты | проект compose `arm-ka` общий для всех worktree: `down -v` удалил бы и тома `postgres-data`/`minio-data` с данными другой работы | человек хотел полный сброс — одна команда `docker compose --profile services down -v` из `docs/dev` |
| 78 | 2026-10-04 | R-37 (design #78): состав и сборка сервисов в Task 6 (7.5): `up -d --build arm-core scan-gateway scan-emulator`, затем `up -d --wait` по тем же именам; `tabular-parser` не поднимался (конвейер `image` его не использует); контекст сборки `scan-gateway` в compose — `../../scan-gateway` от `docs/dev` worktree, т. е. сам worktree ветки (`.wt/slice-0/scan-gateway`), поэтому отдельный `make docker-build` не нужен; `arm-core:dev` пересобран из worktree `arm-core` (кэш, ID образа не изменился) | проверяется шлюз; лишний сервис только добавил бы шум на шине; правка compose не требовалась | если `tabular-parser` нужен для проверки чего-то ещё — отдельный прогон |
| 79 | 2026-10-04 | R-38 (design #79): после Task 6 тестовые строки БД оставлены (`legal_entity` ИНН `7700000001`, четыре `s3_object`/`stored_file`/`recognition_job` — id в отчёте Task 6), а объекты `uploads/…` и `raw/…` в MinIO удалены (`mc rm` поимённо); строки `s3_object` поэтому указывают на несуществующие ключи | по указанию контроллера (dispatch Task 6): строки БД — оставить, объекты MinIO — удалить; бакет до прогона был пуст | чтение этих `s3_object` даст `NoSuchKey`; убрать — SQL в порядке FK (подставить id из отчёта Task 6: `<job_id>` — `015ea48c-d91f-4d90-a7d8-0d559a8739a3`, `1bc7743c-d902-40c5-9009-09bda2de5f72`, `0ea16933-4cc1-4b50-bda9-a97089a918c5`, `9942727c-7f7d-43e8-a82d-925e0140c2e7`; `<file_id>` — `92b342b2-2d65-489e-b252-e26569780373`, `072e922f-b550-461c-9bfc-7cbb1961a46b`, `61a53aaf-af6c-4b30-8651-97c03bd0ba8e`, `e2d4f412-ecd7-4fd3-b2e3-91693432a186`; `<s3_object_id>` — `d569c917-c764-4591-be16-8058a688abf7`, `2ef2d832-d4f1-4819-9467-36bd24bf4c93`, `cd61b5f0-9582-4da5-bc55-77e505a5b32e`, `c81c8d72-9c6f-48f8-bbbb-3c6ce7a43e4f`): `begin; delete from dkb.recognition_job where id in (<job_id>, …); delete from dkb.stored_file where id in (<file_id>, …); delete from dkb.s3_object where id in (<s3_object_id>, …); delete from dkb.legal_entity where inn = '7700000001'; commit;` (порядок: `recognition_job.file_id → stored_file`, `stored_file.s3_object_id → s3_object`, `stored_file.legal_entity_id → legal_entity`; `stored_file.current_recognition_job_id` у этих строк пуст, `document`/`osv_entry` не создавались) либо полный сброс |
| 80 | 2026-10-04 | R-39 (design #80): M-1 финального ревью — при `SCAN_SETTLE_SECONDS > 0` устойчивая ошибка чтения файла ответа (`PermissionError` локально, `STATUS_ACCESS_DENIED` на SMB) чередуется с пустыми опросами (готовность сбрасывает состояние), поэтому `failures != polls` и задание заканчивается `TIMEOUT`, а не `RECOGNITION_FAILED`; код итога остаётся `TIMEOUT` (спека и контракт не меняются), а при `failures > 0` текст сообщения дополняется числом ошибок опроса и описанием последней (`_describe(last_error)`) | не менять код итога и контракт ради диагностики; конверт при проблеме с чтением горячей папки не должен вести к ИС | код итога не `RECOGNITION_FAILED`: диагностика только в тексте сообщения; ядро, фильтрующее по коду, не отличит этот случай от молчания ИС |