18 KiB
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` приводятся к этому |
| 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: сообщение без трассировки | нет |