# Spec Delta: recognition-job-exchange ## Purpose Единственная точка обмена ядра с сервисами распознавания — таблица `recognition_job` (DECISION-4): outbox публикует запросы в NATS, inbox принимает результаты идемпотентно, таймауты и DLQ отражаются в статусе задания, файл результата регистрируется в `s3_object`. ## ADDED 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 = :`. После подтверждения JetStream задание 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 с в `recognition.tabular.request` появляется сообщение, проходящее схему запроса, с `job_id`, `file_id`, `s3.key` исходника, `reply_deadline`, `attempt = 1`, а строка переходит в `published` с заполненным `request_msg_id` #### Scenario: NATS недоступен - **WHEN** публикация завершается ошибкой соединения - **THEN** задание остаётся `queued`, ошибка залогирована, следующий цикл повторяет публикацию; дубликат после восстановления отбрасывается JetStream по `Nats-Msg-Id` ### Requirement: Inbox принимает результат идемпотентно Durable consumer `arm-core-results` на `recognition.result` SHALL валидировать конверт по `recognition-result-message.schema.json`, найти задание по `job_id` и: если `result_msg_id` задания уже равен `Nats-Msg-Id` сообщения — подтвердить без изменений; иначе зарегистрировать файл результата в `s3_object` (`bucket_role = results`, `created_by = `), обновить задание (`status` ∈ {done, needs_check, error}, `service_name`, `service_version`, `finished_at`, `metrics`, `error_code`, `error_message`, `result_schema_version`, `result_s3_object_id`, `result_msg_id`), зафиксировать транзакцию и только затем подтвердить сообщение. Неизвестный `job_id` SHALL быть залогирован и подтверждён. Сообщение с `num_delivered ≥ max_deliver` SHALL перевести задание в `dlq`. #### Scenario: Успешный результат - **WHEN** сервис публикует конверт `status = done` с `result_s3 = {arm-ka, results///result.json}` и `Nats-Msg-Id = result::1` - **THEN** задание получает `status = done`, `result_msg_id = result::1`, `result_s3_object_id` указывает на новую строку `s3_object` с этим ключом, `finished_at` заполнен #### Scenario: Повторная доставка - **WHEN** то же сообщение доставляется второй раз - **THEN** строка `recognition_job` и `s3_object` не меняются, сообщение подтверждено #### Scenario: Ошибка сервиса - **WHEN** конверт содержит `status = error`, `error = {NOT_IMPLEMENTED, …}` без `result_s3` - **THEN** задание получает `status = error`, `error_code = NOT_IMPLEMENTED`, `error_message` из конверта, `result_s3_object_id` пуст #### Scenario: Отравленное сообщение - **WHEN** обработка одного и того же сообщения падает ошибкой БД пять раз подряд (`max_deliver = 5`) - **THEN** на пятой доставке задание переводится в `dlq`, сообщение подтверждено, повторов больше нет ### Requirement: Таймаут ожидания результата Фоновый вахтёр SHALL с периодом 30 с переводить задания `published` или `processing` с `reply_deadline < now()` в `status = timeout`, `error_code = TIMEOUT`, `finished_at = now()`. Replay задания (новая попытка) — вне Среза 0. #### Scenario: Сервис не ответил - **WHEN** задание опубликовано с `reply_deadline` в прошлом и результата нет - **THEN** не позднее чем через 30 с задание имеет `status = timeout` ### Requirement: Соединение с NATS по шаблону, stream — по флагу Ядро SHALL подключаться к NATS с nkeys-seed и JWT, если заданы `OPENBAO__NATS__NATS_SEED/NATS_JWT`, и без учётных данных, если они пусты; при `NATS_MANAGE_STREAM=true` SHALL создать stream `RECOGNITION` (subjects `recognition.>`, окно дубликатов 2 мин) и свой consumer, если их нет. Фоновые задачи SHALL запускаться при `NATS_ENABLED=true`. #### Scenario: Dev-окружение compose - **WHEN** ядро стартует с `DEBUG=true`, `NATS_ENABLED=true`, `NATS_MANAGE_STREAM=true` и пустыми учётными данными - **THEN** stream `RECOGNITION` и consumer `arm-core-results` существуют, в логах нет ошибок подключения #### Scenario: NATS выключен - **WHEN** `NATS_ENABLED=false` - **THEN** приложение стартует, фоновые задачи не запускаются, `/healthcheck` отвечает 200