Andrey Epifancev 4e42a93f54 chore(openspec): архив unified-normalization-core-a и -core-b (#126, #131)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 09:26:03 +04:00

165 lines
15 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.

# recognition-job-exchange Specification
## Purpose
Единственная точка обмена ядра с сервисами распознавания — таблица `recognition_job` (DECISION-4): outbox публикует
запросы в NATS, inbox принимает подтверждения и результаты идемпотентно, потеря сообщений без JetStream компенсируется
повторной публикацией и таймаутами, файл результата регистрируется в `s3_object`.
## Requirements
### Requirement: Outbox публикует задания из `queued`
Фоновый публикатор SHALL с периодом `OUTBOX_POLL_SECONDS` (по умолчанию 5) выбирать задания `status = queued`
(с блокировкой `FOR UPDATE SKIP LOCKED`), формировать сообщение по `recognition-request.schema.json` v1.0 из
`recognition_job ⋈ stored_file ⋈ s3_object` и публиковать в subject по `pipeline` с заголовком
`Nats-Msg-Id = <job_id>:<attempt>`. После успешной публикации задание SHALL получить `status = published`,
`published_at`, `request_msg_id`, `request_subject`; при ошибке публикации задание SHALL остаться `queued`.
#### Scenario: Публикация запроса парсеру
- **WHEN** в `recognition_job` вставлена строка `queued` с `pipeline = tabular`, связанная со `stored_file` (XLSX) и его `s3_object`
- **THEN** в течение 10 с в `files.tabular_uploaded` появляется сообщение, проходящее схему запроса, с `job_id`,
`file_id`, `s3.key` исходника, `reply_deadline`, `attempt = 1`, а строка переходит в `published` с заполненным `request_msg_id`
#### Scenario: NATS недоступен
- **WHEN** публикация завершается ошибкой соединения
- **THEN** задание остаётся `queued`, ошибка залогирована, следующий цикл повторяет публикацию
### Requirement: Ack сервиса переводит задание в `processing`
Получив в `files.tabular_parsed` конверт со `status = processing` для задания в `published`, ядро SHALL установить
`status = processing`, `started_at`, `service_name`, `service_version` и перевести файл задания в
`stored_file.status = PROCESSING`, если `current_recognition_job_id` файла равен этому заданию. Повторный ack для
задания в `processing` SHALL игнорироваться; ack для задания в другом статусе SHALL логироваться и не менять задание.
#### Scenario: Сервис подтвердил
- **WHEN** сервис опубликовал конверт `processing` с `job_id` опубликованного задания
- **THEN** задание имеет `status = processing`, `started_at` заполнен, `service_name = tabular-parser`, файл — `PROCESSING`
### Requirement: Запрос без ack публикуется повторно, затем — `dlq`
Если задание находится в `published` дольше `ACK_TIMEOUT_SECONDS` (по умолчанию 30) без ack, публикатор SHALL
опубликовать запрос повторно с тем же `Nats-Msg-Id` и тем же `attempt`, обновив `published_at` и увеличив счётчик
`metrics.republish_count`. После `OUTBOX_MAX_REPUBLISH` (по умолчанию 3) повторов без ack задание SHALL получить
`status = dlq`, `error_code = NO_ACK`, `finished_at`.
#### Scenario: Сервис недоступен
- **WHEN** ни один инстанс `tabular-parser` не подписан, задание опубликовано
- **THEN** запрос публикуется повторно примерно каждые 30 с; после трёх повторов задание имеет `status = dlq`,
`error_code = NO_ACK`, `request_msg_id` не изменился
#### Scenario: Сервис появился после первого повтора
- **WHEN** сервис подписался между первой и второй публикацией и прислал ack
- **THEN** задание переходит в `processing`, дальнейшие повторы не выполняются
### Requirement: Inbox принимает результат идемпотентно
Для конверта со `status ∈ {done, needs_check, error}` ядро SHALL валидировать его по
`recognition-result-message.schema.json`, найти задание по `job_id` и: если `result_msg_id` задания уже равен
`Nats-Msg-Id` сообщения — ничего не менять; иначе зарегистрировать файл результата в `s3_object` (`bucket_role = results`,
`created_by = <service.name>`; только при наличии `result_s3`), обновить задание (`status`, `service_name`,
`service_version`, `finished_at`, `metrics`, `error_code`, `error_message`, `result_schema_version`, `result_s3_object_id`,
`result_msg_id`) и зафиксировать транзакцию. При `status = error` файл задания (если `current_recognition_job_id` равен
заданию) SHALL получить `stored_file.status = ERROR`; при `done`/`needs_check` файл остаётся `PROCESSING` и SHALL быть
обработан циклом маппинга (спека `osv-result-mapping`); inbox MUST NOT читать файл результата. Неизвестный `job_id` SHALL
быть залогирован. Результат для задания, уже находящегося в финальном статусе (`done`, `needs_check`, `error`,
`timeout`, `dlq`), SHALL логироваться и не применяться.
#### Scenario: Успешный результат
- **WHEN** сервис публикует конверт `status = done` с `result_s3 = {arm-ka, results/<file_id>/<job_id>/result.json}` и
`Nats-Msg-Id = result:<job_id>:1`
- **THEN** задание получает `status = done`, `result_msg_id = result:<job_id>:1`, `result_s3_object_id` указывает на новую
строку `s3_object` с этим ключом, `finished_at` заполнен; файл остаётся `PROCESSING` до маппинга
#### Scenario: Повторная доставка
- **WHEN** то же сообщение получено второй раз
- **THEN** строка `recognition_job` и `s3_object` не меняются
#### Scenario: Ошибка сервиса
- **WHEN** конверт содержит `status = error`, `error = {UNSUPPORTED_FORMAT, …}` без `result_s3`
- **THEN** задание получает `status = error`, `error_code = UNSUPPORTED_FORMAT`, `error_message` из конверта,
`result_s3_object_id` пуст; файл — `ERROR`
#### Scenario: Результат пришёл после таймаута
- **WHEN** задание уже `timeout`, и приходит конверт `done`
- **THEN** задание не меняется, событие залогировано
### Requirement: Таймаут ожидания результата
Фоновый вахтёр SHALL с периодом 30 с переводить задания `processing` и `published` с `reply_deadline < now()` в
`status = timeout`, `error_code = TIMEOUT`, `finished_at = now()`, а их файлы (по `current_recognition_job_id`) — в
`stored_file.status = ERROR`. Публикатор при переводе задания в `dlq` SHALL так же переводить файл в `ERROR`. Replay
задания (новая попытка, `attempt + 1`) — вне этого среза.
#### Scenario: Сервис взял, но не ответил
- **WHEN** задание в `processing` и `reply_deadline` прошёл без результата
- **THEN** не позднее чем через 30 с задание имеет `status = timeout`, файл — `ERROR` с `error_code = TIMEOUT`
### Requirement: Соединение с NATS по шаблону, без JetStream
Ядро SHALL подключаться к NATS с nkeys-seed и JWT, если заданы `OPENBAO__NATS__NATS_SEED/NATS_JWT`, и без учётных данных,
если они пусты; подписываться на subject ответа `NATS_RESULT_SUBJECT` (по умолчанию `files.tabular_parsed`) в queue group `arm-core`; переподключаться автоматически.
Stream'ы и consumer'ы JetStream MUST NOT требоваться. Фоновые задачи SHALL запускаться при `NATS_ENABLED=true`.
#### Scenario: Dev-окружение compose
- **WHEN** ядро стартует с `DEBUG=true`, `NATS_ENABLED=true` и пустыми учётными данными против NATS без JetStream
- **THEN** подписка активна, `/healthcheck` отдаёт `nats = ok`, в логах нет ошибок подключения
#### Scenario: Два инстанса ядра
- **WHEN** запущены два инстанса с `NATS_ENABLED=true` и опубликован один результат
- **THEN** результат обработан ровно одним инстансом (queue group), строка задания изменена один раз
#### Scenario: NATS выключен
- **WHEN** `NATS_ENABLED=false`
- **THEN** приложение стартует, фоновые задачи не запускаются, `/healthcheck` отвечает 200 с `nats = off`
### Requirement: Subject'ы и учётные данные NATS из настроек окружения
Ядро SHALL брать subject'ы обмена из `OPENBAO__NATS__NATS_TABULAR_REQUEST_SUBJECT`, `NATS_IMAGE_REQUEST_SUBJECT`,
`NATS_RESULT_SUBJECT` (умолчания `files.tabular_uploaded`, `files.img_uploaded`, `files.tabular_parsed`): публикует запросы в `tabular_request` и `image_request`, подписан на `result` (queue group `arm-core`).
При старте SHALL проверяться все три имени (непустое, без пробельных символов, без `*` и `>`, без пустых токенов,
попарно различны); при нарушении приложение MUST NOT стартовать. Значения `NATS_JWT` и `NATS_SEED` MUST NOT попадать в
логи, `repr` настроек и тексты ошибок валидации. Используемые subject'ы SHALL писаться в лог при старте и отдаваться в
`/healthcheck` объектом `subjects` с ключами `tabular_request`, `image_request`, `result` при любом значении `nats` (`ok | down | off`).
#### Scenario: Умолчания
- **WHEN** приложение стартует без ключей `OPENBAO__NATS__NATS_*_SUBJECT`
- **THEN** `/healthcheck.subjects` содержит имена `files.*` по умолчанию, обмен идёт по ним
#### Scenario: Subject'ы окружения
- **WHEN** заданы `OPENBAO__NATS__NATS_TABULAR_REQUEST_SUBJECT=test.files.tabular_uploaded`,
`OPENBAO__NATS__NATS_IMAGE_REQUEST_SUBJECT=test.files.img_uploaded`,
`OPENBAO__NATS__NATS_RESULT_SUBJECT=test.files.tabular_parsed`
- **THEN** подписки и публикации идут только по этим subject'ам, `/healthcheck.subjects` их показывает
#### Scenario: Недопустимый subject
- **WHEN** `OPENBAO__NATS__NATS_RESULT_SUBJECT` совпадает с subject'ом запроса или содержит `>`
- **THEN** приложение не стартует, ошибка называет ключ
#### Scenario: JWT не раскрывается
- **WHEN** заданы `NATS_JWT` и `NATS_SEED`, а валидация настроек падает на другом поле
- **THEN** текст ошибки и лог не содержат значений JWT и seed; `repr` настроек показывает их скрытыми
### Requirement: Приём конвертов и результатов обоих MAJOR контракта
Inbox SHALL принимать конверты (включая ack `processing`) и файлы результата и `MAJOR` 1, и `MAJOR` 2 («сначала ядро»,
`recognition-contract`): версия определяется по `schema_version`, конверт валидируется моделью своего `MAJOR`, неизвестный
`MAJOR` (например, `3.0`) отбрасывается с предупреждением в лог (задание закроется по сроку). Файл результата
обрабатывается маппером своего `MAJOR` (`2.0` — маппер результата `2.0`, `1.x` — маппер `1.x`); файл с неизвестным
`MAJOR` получает `ERROR` с `mapping_error`. Ядро SHALL держать копии схем и примеров обоих `MAJOR` (`api/contracts/`),
сверяемые с `docs/contracts/` общим правилом. Предупреждения уровня файла SHALL записываться в
`recognition_job.metrics.warnings` структурами `{code, severity, message, locator?}`: у `2.0` — как пришли, у `1.x` —
каждая строка как `{code: "LEGACY_WARNING", severity: "info", message: <строка>, locator: null}`.
`recognition_job.statements_version` SHALL сохранять `service.statements_version` результата `2.0` (у `1.x` — `NULL`).
#### Scenario: Ack нового сервиса
- **WHEN** сервис публикует ack `processing` со `schema_version = "2.0"`
- **THEN** задание переходит в `processing`, как для ack `1.0`
#### Scenario: Предупреждения 1.x оборачиваются
- **WHEN** конверт `1.0` несёт `metrics.warnings = ["sheet=ОСВ 60 строки без родителя"]`
- **THEN** в `recognition_job.metrics.warnings` — `{code: "LEGACY_WARNING", severity: "info", message: "sheet=ОСВ 60 строки без родителя", locator: null}`
#### Scenario: Предупреждения 2.0 сохраняются как пришли
- **WHEN** конверт `2.0` несёт предупреждение `JSON_FAILED` уровня `error` с `locator`
- **THEN** структура сохранена без изменений
#### Scenario: Неизвестный MAJOR
- **WHEN** приходит конверт со `schema_version = "3.0"`
- **THEN** конверт отбрасывается с предупреждением в лог, задание не меняется
#### Scenario: Результат 2.0 разбирает маппер 2.0
- **WHEN** inbox принял файл результата со `schema_version = "2.0"`
- **THEN** документы создаёт маппер результата `2.0`; результат `1.x` по-прежнему разбирает маппер `1.x`