# Design: Срез 0 — скелет tabular-parser ## Context См. `proposal.md`. Исходное состояние — копия каркаса `arm-core` (`124f63b`): FastAPI с lifespan (SMB, MinIO, NATS), SPA-mount, Alembic, пустые пакеты `models/repositories/services`. Контракт v1.0, ack `processing` и правила `Nats-Msg-Id` — родительский change в docs. Платформа: Python 3.11, NATS без JetStream, Nexus недоступен до прода, CI в Gitea нет. Сервис должен остаться запускаемым платформой так же, как шаблон (`api/run_server.py`, `SERVER__*`, `raisa_config.yml`). ## Goals / Non-Goals **Goals:** сервис без БД/фронта; транспорт NATS ↔ S3 проверен интеграционным тестом, включая повторный запрос; парсер — заменяемый интерфейс. **Non-Goals:** см. proposal; масштабирование (несколько инстансов — queue group даёт это бесплатно, но не тестируется). ## Decisions ### D-1. Вырезаем, а не переписываем Удаляются каталоги и зависимости, перечисленные в proposal; `app.py` упрощается до: настройки, lifespan (MinIO, NATS, запуск воркера), `/healthcheck`, обработчики ошибок, логирование. Остающиеся файлы шаблона не переименовываются — чтобы diff с `arm-core` и будущие обновления шаблона были читаемы. ### D-2. Воркер — подписка core NATS с queue group, ack сразу, обработка в очереди задач `nc.subscribe("recognition.tabular.request", queue="tabular-parser", cb=on_request)`. В `on_request`: валидация → если `job_id` уже в process-local множестве «в работе/обработано» — только ack; иначе добавить в множество, опубликовать ack `processing`, положить задание во внутреннюю `asyncio.Queue`. Рабочая корутина (одна на инстанцию, `WORKER_CONCURRENCY=1`) берёт задания из очереди и выполняет конвейер S3 → парсер → S3 → конверт результата. Множество обработанных `job_id` ограничено по размеру (`DONE_CACHE_SIZE=1000`, LRU) — защита от повторов ядра после результата. *Почему ack сразу:* ядро без JetStream публикует повторно через 30 с без ack; долгий разбор не должен вызывать повторы. *Альтернатива* — pull-семантика JetStream: недоступна. ### D-3. Парсер — протокол `Parser.parse(data: bytes, file_type) -> ParseOutcome` `ParseOutcome` = `status`, `documents` (модели контракта), `metrics`, `error`. `NullParser` всегда отдаёт `error NOT_IMPLEMENTED` с сообщением «разбор XLS/XLSX/XLSB реализуется в Срезе 2». Воркер не знает о реализации; Срез 2 подменяет фабрику. Так транспорт и парсер тестируются раздельно, парсер — без NATS/S3. ### D-4. Ключи S3 и имя сервиса Исходник читается по `request.s3.key` (роль `uploads`), результат пишется в `results/{file_id}/{job_id}/result.json` того же бакета; `service = {name: "tabular-parser", version: settings.VERSION}`. Клиент S3 — `S3Storage` шаблона, дополненный `get_bytes`/`put_json`. ### D-5. Любая ошибка транспорта — результат, не молчание Таблица: запрос не проходит pydantic → `SCHEMA_MISMATCH` (без ack — нечего подтверждать, но результат публикуется с `job_id`, если он читаем); `MAJOR` версии запроса ≠ 1 → `SCHEMA_MISMATCH`; S3 `get` упал → `FILE_READ_ERROR`; исключение парсера → `UNKNOWN_ERROR` с текстом исключения в `error.message` (без трассировки). Публикуется конверт без `result_s3`; `metrics.duration_ms` заполняется всегда. Публикация результата повторяется до `PUBLISH_RETRIES=3` раз с паузой 2 с; если не удалось — лог уровня error, задание дойдёт до `timeout` в ядре. ### D-6. Соединение с NATS — как у ядра, без JetStream Пустые `NATS_SEED/NATS_JWT` → без учётных данных (dev); иначе nkeys/JWT шаблона. Автопереподключение `nats-py` (`max_reconnect_attempts=-1`); при разрыве подписка восстанавливается клиентом. `/healthcheck` отдаёт `nats = ok | down | off` (`off` — при `NATS_ENABLED=false`, R-17). Флаг `NATS_ENABLED` (по умолчанию `true`) — чтобы unit-тесты поднимали приложение без NATS. ### D-7. Тесты в два уровня Unit (`tests/unit`): модели контракта и примеры docs; обработчик запроса с фейками `S3Storage`/`Publisher` (протоколы) — сценарии D-5, повторный запрос, ack до обработки. Интеграционные (`tests/integration`, маркер `integration`): compose-NATS/MinIO — реальный запрос из примера `request-tabular.json` → ack и результат в `recognition.result`, файл в бакете. ### D-8. Dev без Nexus, Python 3.11 `Dockerfile` — `python:3.11-slim`; `make venv` ставит `requirements.txt` без строк `raisa_*` (в этом сервисе их уже нет после D-1, но цель одинакова во всех репозиториях). Конфигураций CI нет: `make check` / `make check-full`. ## Risks / Trade-offs - [Повторный запрос ядра после ack при долгой обработке] → ack публикуется до чтения S3; ядро повторяет только без ack. - [Инстанция упала после ack, до результата] → ядро закроет задание по `reply_deadline` (`timeout`), replay — Срез 3+. - [`NOT_IMPLEMENTED` в проде, если Срез 2 задержится] → код ошибки честный, ядро показывает его аналитику. - [Удалённые зависимости понадобятся позже] → возвращаются по одной строке в `requirements.txt`. ## Migration Plan Нет данных. Удаление каталогов — одним коммитом «chore: вырезан фронт и БД из шаблона» до функциональных изменений, чтобы ревью было читаемым. Откат — revert коммита. ## Open Questions Общие с родительским change (версия Node не нужна этому сервису; параметры NATS банка — к деплою). Специфичных нет. ## Decisions made without discussion | № | Дата | Решение | Обоснование | Цена ошибки | |---|---|---|---|---| | 1 | 2026-10-03 | D-1: вырезать фронт/БД/SMB, не переименовывая оставшееся | читаемый diff с arm-core | нет | | 2 | 2026-10-03 | D-2: queue group `tabular-parser`, ack до обработки, одна рабочая корутина, LRU обработанных `job_id` | NATS без JetStream (ответ 03.10); ядро повторяет без ack | смена параметров; при JetStream — pull-consumer | | 3 | 2026-10-03 | D-3: парсер-заглушка отвечает `NOT_IMPLEMENTED` (код добавлен в контракт родительским change'ем) | честный статус вместо маскировки под `UNSUPPORTED_FORMAT` | нет | | 4 | 2026-10-03 | D-4: результат в `results/{file_id}/{job_id}/result.json` того же бакета | схема ключей `03-architecture.md`, один бакет | смена шаблона ключа в одном месте | | 5 | 2026-10-03 | D-5: транспортные ошибки превращаются в результат; публикация с 3 повторами | ядро не должно ждать таймаута, если сервис жив | нет | | 6 | 2026-10-03 | D-8: `python:3.11-slim`, `make check`/`check-full` вместо CI | ответы пользователя | смена тега | | 7 | 2026-10-03 | Каталог словарей — `api/rules/` (относительно рабочего каталога `api/`) | все команды шаблона идут из `api/` | перенос каталога | | 8 | 2026-10-04 | R-1: проверка старта в задаче 1.2 — через gitignored `api/.env`/окружение; `.env.example` и повторная проверка старта — в 2.2 | 1.2 идёт раньше 2.2 по плану | перенос проверки | | 9 | 2026-10-04 | R-2: маркеры pytest и filterwarnings в `api/pytest.ini`; `pyproject.toml` — только ruff и mypy | `pytest.ini` приоритетнее `[tool.pytest]`; как в arm-core | правка конфига | | 10 | 2026-10-04 | R-3: `.env.test` содержит dev-значения compose (localhost, minioadmin, arm-ka, пустые NATS seed/jwt) | как в arm-core; dev-пароли публичны в docs | нет | | 11 | 2026-10-04 | R-4: шаблонный `core/nats_callback.py` удаляется в задаче 4.3 | заменён воркером; consumer `recognition.result` — зона ядра | восстановить из git | | 12 | 2026-10-04 | R-5: критерий 1.1 «только удаления и `.gitignore`» читается как «удаления, `.gitignore`, `setup/setup.sh`» | npm-шаги setup.sh входят в текст задачи | нет | | 13 | 2026-10-04 | R-6: из `requirements.txt` убираются и транзитивные сироты удалённых пакетов, если без них всё работает | сервис без БД/SMB не должен тянуть их | вернуть строку | | 14 | 2026-10-04 | R-7: README (1.3) может описывать файлы из 2.1/2.2; окончательная сверка существования — в 5.2 и финальном ревью | порядок задач | правка README | | 15 | 2026-10-04 | R-8: `make lint` = ruff check + ruff format --check + mypy src; остальные файлы шаблона форматируются в коммите 2.1 | как arm-core | отключить format-check | | 16 | 2026-10-04 | R-9: трейлер коммитов `Co-Authored-By: Claude Fable 5.1 ` | указание вызывающего, Global Constraints плана | нет | | 17 | 2026-10-04 | R-10: `.env.example` и `.env.test` лежат в `api/` (рядом с `pytest.ini`, как в arm-core) | команды идут из `api/`, `pytest.ini` задаёт `ENV_FILE=.env.test` относительно `api/` | правка двух строк README | | 18 | 2026-10-04 | R-11: `.dockerignore` не добавляется (он в `.gitignore` шаблона, как в arm-core) | на чистом checkout `api/.venv` и `api/.env` в образ не попадают | локально образ тяжелее; добавить `.dockerignore` одной строкой | | 19 | 2026-10-04 | R-12: `api/.env.test` содержит `NATS_ENABLED=false`; интеграционные тесты включают NATS сами | D-6: unit-тесты поднимают приложение без NATS | правка фикстур | | 20 | 2026-10-04 | R-13: `Parser.parse` синхронный, воркер вызывает через `asyncio.to_thread` | разбор Excel CPU-bound; без await внутри | смена сигнатуры протокола и одного вызова в воркере | | 21 | 2026-10-04 | R-14: подключение NATS из `app.py` переезжает в `core/nats.py` (фоновое первое подключение, supervisor; параметра `retry_on_failed_connect` в nats-py 2.14 нет, а `connect()` при `max_reconnect_attempts=-1` не возвращается, пока NATS недоступен — старый lifespan блокировал старт, O-3); шаблонные `put_object/get_object` в `S3Storage` заменяются типизированными `get_bytes/put_json` | нет дублирования; старт при недоступном NATS не должен оставлять воркер навсегда выключенным | восстановить из git | | 22 | 2026-10-04 | R-15: фоновый supervisor подключения к NATS в `core/nats.py` (по образцу arm-core) вместо `retry_on_failed_connect`; половинные учётные данные (только seed или только jwt) — `NatsConfigError` | проверено по исходникам nats-py 2.14; повтор подключения с неполными данными бесполезен | замена на штатный параметр nats-py при его появлении | | 23 | 2026-10-04 | R-16: `PUBLISH_RETRIES=3` — это три повтора после первой попытки (до 4 попыток всего, пауза 2 с между ними); публикация при отсутствии соединения падает сразу и учитывается как неудачная попытка | дословное чтение spec «повторить её до трёх раз» и D-5; окно ≈ 6 с короче типичного рестарта NATS — результат при рестарте теряется, ядро закроет задание по `timeout` (в рамках D-5) | смена одной константы/цикла | | 24 | 2026-10-04 | R-17: `/healthcheck.nats` принимает значения `ok | down | off` (`off` — при `NATS_ENABLED=false`, как в arm-core); spec/README/`.env.example` приводятся к этому | спека определяет только `ok | down`, но выключенный по флагу NATS не является отказом | убрать `off`, отдавать `down` | | 25 | 2026-10-04 | R-18 (D9-1, P-1): невалидный запрос без читаемых `job_id`/`file_id`/`attempt` логируется (error) и отбрасывается — без ack и результата; `attempt` берётся из тела, иначе из заголовка `Nats-Msg-Id` | схема конверта требует `file_id`, id сообщения — `attempt`; выдумывать значения нельзя | задание дойдёт до `dlq` вместо `error`; правка — одна ветка `_reject`; предложение в контракт P-1 | | 26 | 2026-10-04 | R-19 (D9-2): MAJOR `schema_version` проверяется до pydantic-валидации; ответ `SCHEMA_MISMATCH` с понятным текстом | диагностика для аналитика/оператора | нет | | 27 | 2026-10-04 | R-20 (D9-3, P-2): `SCHEMA_MISMATCH` для `2.0` — без ack (spec этого репозитория, D-5); спека контракта в docs говорит «с ack» | этот репозиторий — источник истины; ядро принимает результат и без ack | одна строка, если решат «с ack»; противоречие вынесено в P-2 | | 28 | 2026-10-04 | R-21 (D9-4, P-3): завершённое задание с любым итогом считается «обработано» (LRU `DONE_CACHE_SIZE`, ключ `job_id`); повтор — только ack; `_in_progress` не вытесняется | spec: повтор с обработанным `job_id` получает ack | новая попытка (`attempt+1`) того же `job_id` в этом процессе не обрабатывается, пока он в LRU (P-3) | | 29 | 2026-10-04 | R-22 (D9-5): неудача публикации ack после повторов — error-лог, задание всё равно обрабатывается | результат нужнее ack; ядро принимает результат без ack | при долгом отказе NATS результат тоже не опубликуется — `timeout` в ядре (D-5) | | 30 | 2026-10-04 | R-23 (D9-6): `_connect_s3` всегда возвращает `S3Storage`, `bucket_exists=False` — warning | при недоступном MinIO на старте воркер иначе отвечал бы `FILE_READ_ERROR` до рестарта; miniopy-async открывает соединения на запрос | вернуть ветку `storage is None` | | 31 | 2026-10-04 | R-24 (D9-7): файл результата пишется только для `done | needs_check`; сбой его записи — `UNKNOWN_ERROR` | spec: «файл результата может отсутствовать» при error; отдельного кода в D-5 нет | смена кода в одном месте | | 32 | 2026-10-04 | R-25 (D9-8): остановка воркера — запрет приёма, ожидание очереди до `STOP_TIMEOUT=10 с`, затем отмена; NATS закрывается после воркера, S3 — последним | меньше grace-периода платформы (30 с), больше одной публикации с повторами (~6 с) | константа | | 33 | 2026-10-04 | R-26 (D9-9): `error.message` = `Тип: текст` исключения одной строкой, до 1000 символов, без трассировки | spec: сообщение без трассировки | нет | | 34 | 2026-10-04 | R-27 (D9-12): остановка во время публикации ack закрывается повторной проверкой `_accepting` после ack (warning `error_category=shutdown`, `job_id` убирается из `_in_progress`), без счётчика in-flight `on_request` | после проверки нет `await` до `put_nowait` — гонки нет | при смене модели остановки — переписать | | 35 | 2026-10-04 | R-28 (D9-13): невалидный запрос с известным (в работе/обработан) `job_id` логируется (warning) и отбрасывается, `job_id` сравнивается без учёта регистра | иначе `SCHEMA_MISMATCH` ушёл бы под тем же `result::`, что и настоящий результат | снять guard | | 36 | 2026-10-04 | R-29 (D10-1): повторный запрос в интеграционном тесте шлётся после завершения обработки; случай «во время обработки» покрыт unit-тестами с gate | заглушка отвечает за миллисекунды — гонку в интеграционном тесте не воспроизвести детерминированно | нет | | 37 | 2026-10-04 | R-30 (D10-2): `QUIET_WINDOW = 1.5 с` для отрицательных проверок (нет второго результата / нет ack) | заглушка отвечает за единицы мс; окно названо явно | +3 с к прогону; слишком короткое окно — ложный зелёный | | 38 | 2026-10-04 | R-31 (D10-3): lifespan приложения в интеграционных тестах — function-scoped (новый воркер и LRU на тест) | изоляция LRU и простое завершение | время прогона | | 39 | 2026-10-04 | R-32 (D10-4): `reply_deadline` из примера запроса не меняется (в прошлом) | сервис его не проверяет; контракт не требует | если сервис начнёт проверять дедлайн — править пример в тесте | | 40 | 2026-10-04 | R-33 (D10-5): остановка воркера проверяется в teardown через приватный `worker._tasks` | дешёвая проверка чистой остановки (T10.1: проверка тавтологична — править в cleanup) | хрупкая связь с приватным полем |