116 lines
18 KiB
Markdown
116 lines
18 KiB
Markdown
# 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: сообщение без трассировки | нет |
|