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 team | PIN 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 lock | single-runner: попытка захватить блокировку прогона без ожидания, TTL LockTtl (default 5 мин > MaxRunDuration); backend — PostgreSQL advisory. Блокировка занята → прогон пропускается |
| Row-level | defense-in-depth: claim-выборка с пропуском уже заблокированных строк (skip-locked) — при failover два runner-а берут непересекающиеся строки; двойная провизия идемпотентна (INV-CHAT-2) |
| Batch size | BatchSize строк/цикл (default 100); backlog добирается следующими циклами (порядок по NextAttemptAt asc) |
| Max duration | MaxRunDuration (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).
| Поле | Тип | Обязательность | Ограничения | Описание |
|---|
Id | Guid | Да | PK | Идентификатор строки-намерения. |
DealId | Guid | Да | unique ux_dcp_deal | Сделка; dedupe-ключ (провизия однократна на сделку). |
ChatId | Guid? | Нет | логическая ссылка messenger.chats(id) | Провизированный чат; null — ещё не создан. |
Title | string | Да | varchar(256) | Заголовок чата из deal-created («Объект №…, ЖК …»). |
ProjectId | Guid? | Нет | — | Проект сделки (реплика для навигации). |
DealTenantId | Guid? | Нет | index ix_dcp_tenant | DeveloperCompanyId — scope арбитража. |
Status | DealChatProvisionStatus | Да | хранится как varchar(16), default Provisioning | Provisioning/Ready/Failed. |
Attempts | int | Да | >= 0, default 0 | Число попыток (инкремент при claim). |
NextAttemptAt | DateTimeOffset | Да | index ix_dcp_due (status,next_attempt_at) | Расписание следующей попытки (backoff). |
LastError | string? | Нет | varchar(512) | Последняя ошибка (класс + код, без PII). |
CreatedAt/UpdatedAt | DateTimeOffset | Да | — | Штампы; 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 и провизия — в одной короткой транзакции на строку (блокировка строки живёт до фиксации; сетевых
вызовов внутри нет — публикация события асинхронна).
- Контекст и доступ. Источник запуска — расписание; актор — System (авто-scope-фильтры отключены);
correlationId = jobRunId; AsOf — текущее время. Идемпотентность — по статусу строки и уникальности
чата на сделку (ux_chats_deal_id).
- Проверка предусловий. Внешнего ввода нет. Допустимость состояния задаётся предикатом выборки:
Status = Failed или (Status = Provisioning и UpdatedAt <= AsOf − StuckProvisioningAfter) —
«застрявшие» без прогресса. Cross-tenant разрешён явно (System-worker).
- Что читаем (claim под блокировкой). Захватить single-runner блокировку; если занята — прогон
пропустить. Иначе циклами до пустоты/
MaxRunDuration выбирать кандидатов по предикату шага 2 с
NextAttemptAt <= AsOf, порядок NextAttemptAt asc, размер BatchSize, с пропуском уже заблокированных
строк (индекс ix_dcp_due), вместе с их участниками. Skip-locked пропускает строки параллельного runner-а
и in-flight консюмера.
- Бизнес-правила и переходы. Для каждой строки:
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 + алерт.
- Что меняется. Чат сделки (при необходимости) + участники + строка ленты
ChatEventEntity типа
DealChatProvisioned (подписчиком ChatCreated, INV-CE-2) + переход строки провизии
(Status/ChatId/Attempts/NextAttemptAt/LastError/UpdatedAt). Повторный insert чата безопасен
(ux_chats_deal_id).
- Факты и события. Доменный
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. Консюмер crm → Deal.ChatStatus.
- Согласованность. Граница — на строку: провизия чата + участники + лента + переход строки + публикация
события фиксируются в одном согласованном изменении; блокировка строки держится до фиксации. Poison-строка
(домен-guard отклонил) исключается, логируется, остаётся на следующий цикл.
- Результат. Итог по строке (
DealId, ChatId?, Status, Attempts) — для лог/метрик, не HTTP.
Инфраструктурный сбой → откат строки, освобождение блокировки, ретрай следующим циклом (та же строка ещё
Failed/stuck → выберется снова, идемпотентно).
Граница транзакции
| Элемент | Правило |
|---|
| Единица работы | per row — фиксируется атомарно; блокировка строки живёт до фиксации |
| Сохраняемое состояние | messenger.chats (+participants,chat_events) + переход DealChatProvisionEntry (Status/ChatId/Attempts/NextAttemptAt) + публикация DealChatStatusChanged |
| External calls | Kafka-публикация — после фиксации (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 item | Attempts >= MaxAttempts → Status = Failed + алерт; исключён из выборки до ручного re-enqueue (runbook) |
| Re-emit события | DealChatStatusChanged — key = DealId; консюмер crm идемпотентен (last-write-wins по Status), повтор Ready безопасен |
Наблюдаемость
| Тип | Правило |
|---|
| Logs | cycle 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 |
| Metrics | messenger_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 |
| Traces | span на цикл (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 опубликовано |
Застрявший Provisioning | Provisioning старше StuckProvisioningAfter → повтор, доведение до Ready |
| Идемпотентность | чат уже создан (ux_chats_deal_id) → повтор только досинхронизирует участников + re-emit Ready, дубля чата нет |
| Poison → fail | Attempts >= MaxAttempts → Status=Failed + алерт, исключён из выборки |
| Параллельные инстансы | блокировка → один runner; при обходе skip-locked не даёт двойного claim; двойная провизия идемпотентна |
| Гонка с консюмером | консюмер зафиксировал Ready до тика → оптимистический конфликт/вне выборки, worker не дублирует |
| Cancellation | остановка в цикле → текущая строка докоммичена или откачена, блокировка снята, graceful stop |
Обратные ссылки
Автоссылки: где используется этот документ.
Связанные документы
- Дом чата/провизии (INV-CHAT-2,
ChatStatus, Deal-чат): chat.md,
participant.md (INV-PART-1), chat-event.md
(DealChatProvisioned); контракт готовности — deal-chat-status-changed.md.
- Триггер провизии: consumers/deal-created.md,
источник — crm/events/deal-created.md; README «Background jobs».
- Прецедент паттерна (reliability-таблица, skip-locked, single-runner):
../../news/background/snapshot-detach-retry.md,
../../promotion/background/expire-promotions.md.
- ADR: 0055 мини-мессенджер,
0056 simplicity-first (polling без Redis),
0052 tenancy (System cross-tenant).
- Шаблон: background-job-template.