diff --git a/api/src/core/worker.py b/api/src/core/worker.py index b5a8415..cab3f91 100644 --- a/api/src/core/worker.py +++ b/api/src/core/worker.py @@ -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) diff --git a/api/tests/unit/test_worker.py b/api/tests/unit/test_worker.py index c6fc0ce..5209f5f 100644 --- a/api/tests/unit/test_worker.py +++ b/api/tests/unit/test_worker.py @@ -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) diff --git a/api/tests/worker_helpers.py b/api/tests/worker_helpers.py index 74f4df8..790f4bf 100644 --- a/api/tests/worker_helpers.py +++ b/api/tests/worker_helpers.py @@ -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: diff --git a/openspec/changes/slice-0-skeleton/design.md b/openspec/changes/slice-0-skeleton/design.md index 13e1831..521b85c 100644 --- a/openspec/changes/slice-0-skeleton/design.md +++ b/openspec/changes/slice-0-skeleton/design.md @@ -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: `` — `015ea48c-d91f-4d90-a7d8-0d559a8739a3`, `1bc7743c-d902-40c5-9009-09bda2de5f72`, `0ea16933-4cc1-4b50-bda9-a97089a918c5`, `9942727c-7f7d-43e8-a82d-925e0140c2e7`; `` — `92b342b2-2d65-489e-b252-e26569780373`, `072e922f-b550-461c-9bfc-7cbb1961a46b`, `61a53aaf-af6c-4b30-8651-97c03bd0ba8e`, `e2d4f412-ecd7-4fd3-b2e3-91693432a186`; `` — `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 (, …); delete from dkb.stored_file where id in (, …); delete from dkb.s3_object where id in (, …); 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`: диагностика только в тексте сообщения; ядро, фильтрующее по коду, не отличит этот случай от молчания ИС |