Перейти к основному содержимому

Background Job: ChatProvisionRetryJob

Назначение

ChatProvisionRetryJob — периодический фоновый worker сервиса messenger, гарантирующий доведение провизии чата сделки до готовности, когда синхронная обработка события pin.crm.deal-created.v1 в консюмере deal-created не завершилась (сбой БД/Kafka при создании ChatEntity и участников). Готовность провизии (Provisioning/Ready/Failed) — не статус чата (chat.md), а проекция на стороне crm (Deal.ChatStatus), транслируемая событием pin.messenger.deal-chat-status-changed.v1.

Надёжность обеспечивает операционная reliability-таблица messenger.deal_chat_provisions (строка-намерение на DealId, не доменная сущность): консюмер deal-created пишет её в той же транзакции, что и попытку провизии; на успехе → Ready + событие, на сбое строка остаётся Failed или зависает в Provisioning. Этот job — сеть безопасности: вычитывает Failed и «застрявшие» Provisioning, идемпотентно повторяет провизию (чат уникален по DealId, участники — set-реконсиляция) и повторно публикует событие готовности. Цикл — polling без Redis/планировщика (ADR-0056 п.3): выборка кандидатов с single-runner блокировкой, переход и публикация фиксируются атомарно.

Промежуточное состояние и окно согласованности. Между созданием сделки и готовностью чата crm держит Deal.ChatStatus = Provisioning (по последнему полученному событию) — в UI сделки чат отображается как «создаётся», открыть его нельзя. На happy-path окно — доли секунды (синхронный консюмер deal-created); при сбое консюмера чат станет доступен только после успешного прогона этого job (NextAttemptAt + один PollingInterval), а при исчерпании попыток crm получит Status = Failed и сможет показать сделку с недоступным чатом/кнопкой повтора. Событие готовности идемпотентно (last-write-wins по Status), поэтому повторная публикация Ready промежуточное состояние лишь подтверждает, не откатывает.

MetadataЗначение
Owning teamPIN Platform (Comms squad)
Статус документаdraft
Область действияworker chat-provision-retry, таблица messenger.deal_chat_provisions, агрегаты messenger.chats/participants/chat_events, топик pin.messenger.deal-chat-status-changed.v1
Assumptions / ограниченияцелевой объём 1–10k активных сделок (ADR-0056); happy-path провизии несёт консюмер deal-created, job покрывает только сбойные строки (steady-state Failed ≈ 0); провизия идемпотентна (ux_chats_deal_id, INV-CHAT-2) — повтор безопасен; заявлен в README «Background jobs»
ЗависимостиPostgreSQL (messenger), Kafka (публикация pin.messenger.deal-chat-status-changed.v1)

Классификация

ПолеЗначение
Сервисmessenger
Тип jobпериодический (PeriodicTimer)
Triggerтаймер каждые PollingInterval (default 60 с, без внешнего планировщика)
Критичностьtier-2 (UX чата сделки; сбой → задержка чата, не потеря данных — провизия терминализуется идемпотентно)

Расписание и locking

АспектПравило
Scheduleфиксированный PollingInterval (default 60 с, UTC); следующий тик не стартует, пока идёт текущий цикл
Distributed locksingle-runner: попытка захватить блокировку прогона без ожидания, TTL LockTtl (default 5 мин > MaxRunDuration); backend — PostgreSQL advisory. Блокировка занята → прогон пропускается
Row-leveldefense-in-depth: claim-выборка с пропуском уже заблокированных строк (skip-locked) — при failover два runner-а берут непересекающиеся строки; двойная провизия идемпотентна (INV-CHAT-2)
Batch sizeBatchSize строк/цикл (default 100); backlog добирается следующими циклами (порядок по NextAttemptAt asc)
Max durationMaxRunDuration (default 3 мин); остаток — следующим тиком
Cancellationостановка хоста учитывается на каждом обращении к хранилищу; текущая строка докоммичивается или откатывается целиком, блокировки освобождаются

Входная модель

Внешнего runtime-входа нет: поведение задаёт ChatProvisionRetryOptions (валидация на старте: PollingInterval, BatchSize > 0, MaxAttempts > 0, StuckProvisioningAfter (default 5 мин), backoff BackoffBase/BackoffFactor/BackoffCap (default 1 мин / 2.0 / 15 мин + jitter)); рабочий вход — строки messenger.deal_chat_provisions.

Reliability-таблица DealChatProvisionEntry (messenger.deal_chat_provisions)

Операционная структура job-а (не доменная сущность): идентификатор, статус, счётчик попыток, штампы и оптимистическая блокировка. Снимок сделки для повторной провизии — типизированные колонки (без json).

ПолеТипОбязательностьОграниченияОписание
IdGuidДаPKИдентификатор строки-намерения.
DealIdGuidДаunique ux_dcp_dealСделка; dedupe-ключ (провизия однократна на сделку).
ChatIdGuid?Нетлогическая ссылка messenger.chats(id)Провизированный чат; null — ещё не создан.
TitlestringДаvarchar(256)Заголовок чата из deal-created («Объект №…, ЖК …»).
ProjectIdGuid?НетПроект сделки (реплика для навигации).
DealTenantIdGuid?Нетindex ix_dcp_tenantDeveloperCompanyId — scope арбитража.
StatusDealChatProvisionStatusДахранится как varchar(16), default ProvisioningProvisioning/Ready/Failed.
AttemptsintДа>= 0, default 0Число попыток (инкремент при claim).
NextAttemptAtDateTimeOffsetДаindex ix_dcp_due (status,next_attempt_at)Расписание следующей попытки (backoff).
LastErrorstring?Нетvarchar(512)Последняя ошибка (класс + код, без PII).
CreatedAt/UpdatedAtDateTimeOffsetДаШтампы; CreatedAt — база SLO-лага.

Начальный состав участников — типизированный дочерний DealChatProvisionMemberEntity ({ UserId: Guid, Role: ParticipantRole }, participant.md); при повторе участники добавляются идемпотентно (реактивация — INV-PART-1). Enum DealChatProvisionStatus { Provisioning, Ready, Failed } совпадает со значениями Status события deal-chat-status-changed. Актор — System (cross-tenant scan разрешён явно — ADR-0052 п.3); публичного входа нет.

Алгоритм

Claim и провизия — в одной короткой транзакции на строку (блокировка строки живёт до фиксации; сетевых вызовов внутри нет — публикация события асинхронна).

  1. Контекст и доступ. Источник запуска — расписание; актор — System (авто-scope-фильтры отключены); correlationId = jobRunId; AsOf — текущее время. Идемпотентность — по статусу строки и уникальности чата на сделку (ux_chats_deal_id).
  2. Проверка предусловий. Внешнего ввода нет. Допустимость состояния задаётся предикатом выборки: Status = Failed или (Status = Provisioning и UpdatedAt <= AsOf − StuckProvisioningAfter) — «застрявшие» без прогресса. Cross-tenant разрешён явно (System-worker).
  3. Что читаем (claim под блокировкой). Захватить single-runner блокировку; если занята — прогон пропустить. Иначе циклами до пустоты/MaxRunDuration выбирать кандидатов по предикату шага 2 с NextAttemptAt <= AsOf, порядок NextAttemptAt asc, размер BatchSize, с пропуском уже заблокированных строк (индекс ix_dcp_due), вместе с их участниками. Skip-locked пропускает строки параллельного runner-а и in-flight консюмера.
  4. Бизнес-правила и переходы. Для каждой строки: Attempts += 1, NextAttemptAt = AsOf + backoff(Attempts). Провизия: найти чат по DealId; если его нет — создать чат сделки (Active, Kind = Deal, INV-CHAT-2) и поднять доменный факт ChatCreated; для каждого участника строки — добавить его в чат (идемпотентно, INV-PART-1). Маппинг: ChatId ← id созданного/найденного чата; Status ← Ready при успехе. Исчерпание попыток (Attempts >= MaxAttempts) → Status = Failed + алерт.
  5. Что меняется. Чат сделки (при необходимости) + участники + строка ленты ChatEventEntity типа DealChatProvisioned (подписчиком ChatCreated, INV-CE-2) + переход строки провизии (Status/ChatId/Attempts/NextAttemptAt/LastError/UpdatedAt). Повторный insert чата безопасен (ux_chats_deal_id).
  6. Факты и события. Доменный ChatCreated → строка ленты chat_events. Интеграционное событие DealChatStatusChanged — топик pin.messenger.deal-chat-status-changed.v1 (ContractVersion = 1, ключ/ AggregateId = DealId, AggregateType = "DealChatProvision", payload { DealId, ChatId?, Status, OccurredAt }, без PII) — через транзакционную публикацию (outbox); контракт deal-chat-status-changed.md. Консюмер crmDeal.ChatStatus.
  7. Согласованность. Граница — на строку: провизия чата + участники + лента + переход строки + публикация события фиксируются в одном согласованном изменении; блокировка строки держится до фиксации. Poison-строка (домен-guard отклонил) исключается, логируется, остаётся на следующий цикл.
  8. Результат. Итог по строке (DealId, ChatId?, Status, Attempts) — для лог/метрик, не HTTP. Инфраструктурный сбой → откат строки, освобождение блокировки, ретрай следующим циклом (та же строка ещё Failed/stuck → выберется снова, идемпотентно).

Граница транзакции

ЭлементПравило
Единица работыper row — фиксируется атомарно; блокировка строки живёт до фиксации
Сохраняемое состояниеmessenger.chats (+participants,chat_events) + переход DealChatProvisionEntry (Status/ChatId/Attempts/NextAttemptAt) + публикация DealChatStatusChanged
External callsKafka-публикация — после фиксации (at-least-once); внутри цикла синхронных внешних вызовов нет
Частичная ошибкаcontinue — poison-строка исключается и логируется, остальной батч фиксируется построчно; инфраструктурный сбой — откат строки, ретрай следующим циклом

Идемпотентность и retry

СценарийПравило
Job restart / повторный циклCheckpoint не нужен: прогресс = сам Status; зафиксированный Ready невыбираем → повтор безопасен
Дубликат провизииux_chats_deal_id (INV-CHAT-2) + ux_dcp_deal — второй чат/строка не создаются; повтор — no-op
Параллельные инстансыsingle-runner блокировка → один runner; при failover-лаге skip-locked разводит строки, двойная провизия идемпотентна
Гонка worker ↔ консюмер deal-createdСтрока под консюмером заблокирована → worker её пропускает (skip-locked); если консюмер зафиксировал Ready между чтением/записью — оптимистический конфликт, проигравший перечитывает (уже Ready → вне выборки)
Повторяемая ошибкаFailed/stuck → экспоненциальный backoff (BackoffBase·Factor^n, cap, jitter); pre-increment Attempts при claim
Poison itemAttempts >= MaxAttemptsStatus = Failed + алерт; исключён из выборки до ручного re-enqueue (runbook)
Re-emit событияDealChatStatusChanged — key = DealId; консюмер crm идемпотентен (last-write-wins по Status), повтор Ready безопасен

Наблюдаемость

ТипПравило
Logscycle started (jobRunId, AsOf); batch claimed (rows); chat provisioned (DealId, ChatId, Attempts); provision retry (DealId, Attempts, errorCode); provision failed (DealId, poison); cycle completed (provisioned, retried, failed, DurationMs). DealId/ChatId — не PII
Metricsmessenger_chat_provision_failed (gauge — backlog Failed+stuck); messenger_chat_provision_oldest_age_seconds (gauge — now − min(CreatedAt)); messenger_chat_provision_retried_total; messenger_chat_provision_succeeded_total; messenger_chat_provision_poison_total; лаг публикации pin.messenger.deal-chat-status-changed.v1
Tracesspan на цикл (jobRunId), вложенный span на попытку провизии (DealId, Attempts); DealTenantId per item
Alertsпровизия Failed > 10/ч (README «Alerts»); рост oldest_age_seconds; poison_total > 0; job не отработал > 2 интервалов; backlog публикации > 1000

Связанный runbook

СценарийRunbook
Провизия Failed растётпроверить доступность PostgreSQL/Kafka; после восстановления backlog разгребётся сам — ../runbooks/chat-provision-retry.md (будущий путь)
Poison-строка (Status=Failed, Attempts>=Max)разобрать LastError; ручной re-enqueue (Attempts = 0, NextAttemptAt = now); сверка DealId ↔ чат
Worker стоит (нет прогонов)проверить hosted-service/pod, PollingInterval, застрявшую блокировку прогона

Тесты

ТестОжидание
Empty batchнет Failed/stuck строк — no-op success, мутаций/событий нет
Сбой консюмера → повторстрока Failed без чата → job создаёт ChatEntity + участников, Status=Ready, событие Ready опубликовано
Застрявший ProvisioningProvisioning старше StuckProvisioningAfter → повтор, доведение до Ready
Идемпотентностьчат уже создан (ux_chats_deal_id) → повтор только досинхронизирует участников + re-emit Ready, дубля чата нет
Poison → failAttempts >= MaxAttemptsStatus=Failed + алерт, исключён из выборки
Параллельные инстансыблокировка → один runner; при обходе skip-locked не даёт двойного claim; двойная провизия идемпотентна
Гонка с консюмеромконсюмер зафиксировал Ready до тика → оптимистический конфликт/вне выборки, worker не дублирует
Cancellationостановка в цикле → текущая строка докоммичена или откачена, блокировка снята, graceful stop

Обратные ссылки

Автоссылки: где используется этот документ.

Связанные документы