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

15 KiB
Raw Blame History

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