Andrey Epifancev 82756b8c97 docs: D-6 — значение nats=off в healthcheck (R-17)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-04 03:15:53 +04:00

116 lines
18 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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 <noreply@anthropic.com>` | указание вызывающего, 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: сообщение без трассировки | нет |