15 KiB
Raw Blame History

Design

Context

Мотивация и объём — proposal.md. Сейчас upload создаёт recognition_job в queued, а recognition_exchange.py публикует outbox, принимает ack/результаты и закрывает просроченные задания. attempt при повторной публикации не растёт. Механизма ручной новой попытки нет. file_status.py уже защищает файл от событий нетекущего задания. DocumentService умеет confirm; привязки централизованы в consolidation_documents.py. Модель и миграции — v5.19.

Основание для ОСВ — DBML v5.20 и §9.5 п.12; для повтора — AURORA-1442, DBML v5.21, §9.4 и §9.5 п.13. FILE_REPROCESS уже опубликован в модели v5.21; изменение enum в v5.20 не требуется. Пакет по consolidation_id и периметру выбранной версии дополнительно согласован пользователем. Этот change содержит только план: соседний docs не изменён.

Goals / Non-Goals

Goals: атомарные правки и повтор, сохранение первичных данных и истории, отсутствие дублей при гонках, единая проверка допустимости для одиночного и пакетного повтора, совместимость с действующим outbox.

Non-Goals: новые фоновые воркеры, доставка через JetStream, вычисляемые ORM-таблицы, изменение сумм и субсчетов ОСВ. Денежные значения сохраняют Decimal/numeric(18,2), рубли; правка шапки их не пересчитывает.

Decisions

D1. Сервис ручного повтора и API

Новый services/file_retry.py, фабрика DI и тонкие маршруты:

  • POST /api/v1/files/{file_id}/retry → 202 FileOut.
  • POST /api/v1/consolidations/versions/{version_id}/files/retry → 202 RetryBatchOut.
  • PATCH /api/v1/documents/{id}/osv-header → 200 DocumentOut (endpoints/files/documents.py).

Первые два маршрута не требуют чтения/записи S3 и синхронной публикации NATS: достаточно транзакции outbox. Для файла повторно используется тот же s3_object; path загрузки содержит id первой попытки, это допустимо: ключ исходника берётся из реестра, не конструируется из id новой попытки. Ответы и коды отказов определены в дельтах primary-files/documents-api; новые типы DTO — в schemas/files.py и schemas/documents.py. Названия retry-маршрутов и 202 — проектное решение, в исходной задаче они не заданы.

D2. Новая попытка вместо обновления прежнего job

Создать новый UUID job, queued, attempt + 1; перенести file_id, pipeline, max_attempts. schema_version брать из CONTRACT_VERSION, request_subject — из текущих настроек NATS, как при загрузке; версию контракта и subject прежнего задания не копировать. Ошибки, метрики, result_s3_object_id, service metadata, message ids и времена старой попытки не копируются. requested_at — сейчас, reply_deadline формируется публикатором. Переключить current_recognition_job_id и вернуть файл из ERROR в UPLOADED. Прежний job неизменен. Это соответствует уточнённой задаче и не требует изменения дедупа парсера по старому job_id (OQ-73). Автоматический republish остаётся прежним job/attempt; это другая операция.

D3. Пакет, частичные отказы и версии

Отбор repositories/files.py::retry_candidates выбирает только ERROR-файлы выбранной консолидации по stored_file.consolidation_id, с ЮЛ из периметра версии и policy ЮЛ, до любых блокировок. Остальные статусы не блокируются и не попадают в skipped; после блокировки статус ERROR перепроверяется. Файл без документов не отбирается через consolidation_document. Дедуп по file_id. Согласованный пользователем общий набор у нескольких версий сохраняется; повтор не создаёт версионной копии файла.

Проектное решение для смешанного пакета: общие ошибки прав/видимости/статуса версии отклоняют запрос целиком; непригодность конкретного файла возвращается в skipped, остальные принимаются. accepted и skipped стабильны по file_id. Пустой пакет — 202. Неожиданная ошибка БД откатывает весь запрос, а не маскируется как skipped. Одна транзакция на запрос, сервис не вызывает commit. Новых переходов статуса версии от ручного retry нет.

Допустимые версии — IN_WORK_STATUSES; брать пользовательскую consolidation_lock не требуется. Для одиночного файла с consolidation_id проверить наличие такой версии с его ЮЛ; файл без контекста — по policy ЮЛ. Это консервативное проектное правило для отсутствующего version_id, его нужно явно перенести в §9.4.

D4. Блокировки и гонки

Retry следует порядку работающего обмена: текущее задание FOR UPDATE → файл FOR UPDATE → версии по id. В одиночном повторе только рабочие версии берутся FOR SHARE: статус и периметр защищены от изменений, обычное чтение не блокируется, VERIFIED/CANCELLED вообще не блокируются. После блокировки файла перечитать current_recognition_job_id; если изменился, одиночный запрос получает FILE_NOT_RETRYABLE, пакет пропускает файл. Не захватывать новое текущее задание, уже удерживая файл: это инвертирует порядок обмена. Лимит и отсутствие документов проверять заново под блокировкой. Для пакета сначала блокировать все отобранные прежние jobs по id, затем файлы по id, затем версии по id; после захвата версий перечитать статус и состав периметра. Не начинать пакет с FOR UPDATE версии. OpenAPI одиночного повтора описывает FILE_NOT_RETRYABLE/MAX_ATTEMPTS_REACHED/VERSION_NOT_EDITABLE; для пакетного 409 только VERSION_NOT_EDITABLE, остальные причины возвращаются в skipped.

Для PATCH — файл → документ FOR NO KEY UPDATE → существующая advisory-блокировка привязок ГК → версии по id, как при confirm/delete. Проверить связанные статусы под блокировками и не использовать lock_editable_version, требующий пользовательскую consolidation_lock. Сериализация на файле защищает счёт от параллельного PATCH; уникальный индекс остаётся последней защитой, IntegrityError переводится в DUPLICATE_ACCOUNT через SAVEPOINT с повторным чтением конфликтующего document_id.

D5. Шапка и подтверждение

OsvHeaderPatch использует StrictInt для period_year, StrictStr для period_quarter/osv_account_code, StrictBool для is_cumulative; каждое поле допускает null, отсутствие определяется exclude_unset. Pydantic возвращает стандартный 422 для неверного типа до вызова обработчика. Порядок доменных ошибок §9.5 п.12 применяется к телу с корректными типами; границы года и набор кварталов проверяет домен. Чистая функция domain/osv_header.py строит итоговую шапку, нормализует счёт существующей normalize_account_code, различает отсутствующие и переданные поля, проверяет целостность периода. ORM-операции — отдельный services/osv_header.py с доступом из DocumentService/DI. Пустое тело или нормализованное равенство текущему — no-op; проверки доступа и конфликтов сохраняются.

Перед первой фактической правкой сохранить все четыре исходных поля JSON, используя сериализуемые строки enum. На каждой правке сравнивать итог с неизменным original_values. При возврате исходного is_modified снимается, original_values и история остаются. Не создавать новый document/file_ordinal. У CONFIRMED снять подтверждение и только рабочие привязки; CANCELLED сохранить. Другие статусы не менять. period_check/counterparty_check и document_issue не пересчитываются этим PATCH. Повторный confirm использует существующие правила привязки по исправленным полям, автозамена файлов не вызывается.

D6. Аудит и согласование модели

FILE_REPROCESS — значение audit_action из DBML v5.21; раздел PRIMARY_DOCUMENTS, объект stored_file, одно событие на принятый файл. before_value — прежнее задание: job_id, attempt, status задания, error_code; after_value — новое задание: job_id, attempt. Для пакета в after_value добавить consolidation_id. Сохранить автора, IP и User-Agent. Статус файла в этот payload не подставляется. Автоматический обмен по OQ-55 по-прежнему без такого аудита. PATCH — RECOGNIZED_DATA_EDIT, RECOGNITION, объект document, изменённые поля и сброшенное подтверждение, unbound_version_ids. Всё в транзакции мутации. Переносить аудит повтора на FILE_UPLOAD отвергнуто: физической загрузки нет.

Risks / Trade-offs

  • Поздний ответ старого job → существующая защита финального статуса и current job, регрессия inbox и маппера.
  • Взаимоблокировка с confirm/delete/status → единый порядок и интеграционные тесты на отдельных соединениях.
  • Пакет охватывает общие файлы консолидации → явно описанный состав и повторная проверка текущего job.
  • Расхождение с источником истины → сверка ORM с опубликованной DBML v5.21; исходная v5.20 остаётся неизменной.
  • Папка ../fixtures сейчас недоступна → до golden/e2e обеспечить доступ к репозиторию реальных ОСВ РБ; отсутствие данных не считать успешной проверкой. Гонки и статусы проверяются фабриками БД без поддельных форматов.
  • Временный флаг периода может маскировать исправление периода → тесты с флагом false и true, сам флаг не удалять.
  • Откат после возврата к исходному удаляет original_values при is_modified=false → следовать утверждённому условию v5.20; историю правок сохраняет audit_event, исходник/JSON остаются в S3; ограничение описать в README.

Migration Plan

  1. Использовать опубликованные DBML v5.20/v5.21 и §9.5 п.12–13. В docs уточнить согласованный отбор пакета и описание API; повторно вводить FILE_REPROCESS в модель не требуется.
  2. Миграция 0008_dbml_v5_20 после 0007: только четыре колонки document; старые строки получают is_modified=false, остальные NULL. Существующие документы и привязки не меняются.
  3. Отдельная миграция 0009_dbml_v5_21 после 0008: только FILE_REPROCESS в audit_action, после FILE_REPLACE как в DBML. Итоговые DBML_VERSION, путь Makefile и сверка схемы — v5.21.
  4. Сначала схема, затем приложение; перезапуск фоновых задач обычным порядком. Старые job и файлы не мигрировать.
  5. Downgrade 0009 → 0008 запрещён до DDL при наличии FILE_REPROCESS в аудите, события не переписываются. На совместимых данных восстанавливается enum v5.20 без FILE_REPROCESS. Downgrade 0008 → 0007 отдельно запрещён при document.is_modified=true; иначе удаляются только четыре колонки. Проверить оба перехода по отдельности, полный цикл на чистой/непустой БД и нестандартный DB_SCHEMA.
  6. Обновить frontend-api-guide и README; UI не выкладывается этой задачей.