Consumer: Deal Chat Status (pin-crm-deal-chat-status)
Назначение
Консюмер Deal Chat Status поддерживает проекцию готовности чата сделки — поле DealEntity.ChatStatus
(Pending/Provisioning/Ready/Failed) — по событиям сервиса messenger. Каждое событие переносит
текущий статус провизии чата из messenger в это денормализованное поле сделки. Проекция
идемпотентна (дубли не меняют состояние) и монотонна: Ready — поглощающее терминальное состояние,
регресс из него запрещён. Собственных интеграционных событий консюмер не публикует (конец цепочки).
Чат сделки хранится и управляется в messenger; CRM хранит только статус-проекцию
(DealEntity.ChatStatus). Идентификатор чата на сделке не хранится.
Owning-сервис и триггер
| Поле | Значение |
|---|---|
| Сервис/контекст | crm |
| Consumer | Deal Chat Status |
| Обрабатываемое событие | pin.messenger.deal-chat-status-changed |
| Topic | pin.messenger.deal-chat-status-changed.v1 |
| ContractVersion | 1; при несовпадении версии события — warn-and-proceed (метрика crm_chat_status_contract_mismatch_total) |
| Partition key | DealId → все статус-события одной сделки в одной партиции, строгий per-deal порядок (основа монотонности) |
| Consumer group | pin-crm-deal-chat-status |
| Producer | messenger (источник истины; контракт — ../../messenger/events/deal-chat-status-changed.md) |
| Эффект (событие → что меняется) | Обновляет проекцию DealEntity.ChatStatus (см. domain/deal.md) |
Контекст события
- Из метаданных события доступны: идентификатор сообщения
MessageId(для дедупликации), время смены статуса вmessenger(CreatedAt), версия контракта, идентификатор сделки (DealId). - Актор — системный контекст консюмера (не пользовательский): проекция обновляется без actor-стемпинга
бизнес-полей; tenant определяется по
TenantIdиз события с явной сверкой противTenantIdсделки (fail closed при несоответствии). - Корреляция сквозного трейса восстанавливается из метаданных события.
- Идемпотентность: на уровне консюмера — по ключу
crm:deal-chat-status:{MessageId}; на бизнес-уровне — монотонный rank-guard (INV-CHAT-1, шаг 4).
Входные данные / payload события
Контракт внешний (messenger); ниже — только поля, которые консюмер реально читает, и их маппинг.
Значения Status — enum DealChatStatus (дом — domain/deal.md, раздел «Enum
DealChatStatus»); messenger эмитит Provisioning/Ready/Failed (Pending — CRM-side default
до первого события, продюсером не шлётся).
| Поле payload | Тип | Обязательность | Как используется (маппинг) |
|---|---|---|---|
DealId | Guid | Да | Ключ проекции — идентифицирует сделку (DealEntity.Id), к которой применяется статус |
TenantId | Guid | Да | DeveloperCompanyId; сверяется с TenantId сделки — при несоответствии fail closed (CRM_CHAT_STATUS_TENANT_MISMATCH, DLQ) |
Status | DealChatStatus (string enum) | Да | Новый статус проекции → DealEntity.ChatStatus; неизвестное значение → CRM_CHAT_STATUS_UNKNOWN (DLQ) |
ChatId | Guid | Да | Id чата messenger; не сохраняется на сделке (ADR-0055) — только лог/трейс |
FailureReason | string? | Нет (только при Status = Failed) | Диагностика в лог (не хранится); PII не содержит |
ChangedAt | DateTimeOffset | Да | Момент смены в messenger; диагностика лага/порядка (авторитет порядка — partition key DealId) |
Алгоритм
- Контекст события. Восстановить метаданные события (идентификатор сообщения, идентификатор сделки,
время, версия контракта); при несовпадении версии — warn-and-proceed. Проверить идемпотентность по
ключу
crm:deal-chat-status:{MessageId}: если событие уже обработано → пропустить как дубль (ack, без изменений). - Проверка входа и предусловий.
DealId,TenantId,ChatIdзаданы; значениеStatusраспознано какDealChatStatus— неизвестное значение →CRM_CHAT_STATUS_UNKNOWN(non-retriable → DLQ + алерт). Найти сделку поDealId: если её нет →CRM_DEAL_NOT_FOUND(retriable: узкое окно read-after-write — CRM сам создал сделку до реакции messenger; отсутствие — аномалия, после исчерпания ретраев DLQ); если сделка soft-deleted → skip + warn (статус чата неактуален). Сверить tenant:TenantIdсобытия ≠TenantIdсделки →CRM_CHAT_STATUS_TENANT_MISMATCH(non-retriable → DLQ). Статус самой сделки (DealStatus) не проверяется:ChatStatusортогонален жизненному циклу сделки (терминальныеCompleted/Cancelledтоже принимают статус чата — messenger может закрыть/зафейлить чат). - Монотонный rank-guard (INV-CHAT-1). Ранг:
Pending = 0,Provisioning = 1,Failed = 1,Ready = 2. Новый статус применяется, только если он продвигает ранг (rank(new) > rank(current)) либо равен по рангу, но отличается по значению (штатная осцилляцияProvisioning ↔ Failedпри ретрае провизии messenger). Если текущий статус ужеReady(максимальный ранг) — поглощение: событие устаревшее/дубль (skipped-stale, метрикаcrm_chat_status_stale_total), отметка идемпотентности ставится, статус не меняется, ack. - Применение. Если статус продвигает ранг — обновить
DealEntity.ChatStatusна новое значение иUpdatedAt. Обновляется только это проекционное поле; доменное событие не поднимается (это проекция, а не бизнес-переход агрегата — во избежание повторной публикации). Обновление проекции и отметка идемпотентности фиксируются в одном согласованном изменении; параллельные правки сделки (drag-n-drop/решения сторон) пишут другие поля и не конфликтуют с этой записью. Offset подтверждается только после успешной фиксации. - Результат. Успех → ack. Retriable-код (
CRM_DEAL_NOT_FOUND) → retry по политике (таблица ниже); non-retriable → DLQ + алерт. Метрики/логи — раздел «Наблюдаемость».
| Статус | Ранг |
|---|---|
Pending | 0 |
Provisioning | 1 |
Failed | 1 |
Ready | 2 (поглощающий, терминальный) |
Диаграмма
Идемпотентность и replay
- На уровне консюмера: ключ
crm:deal-chat-status:{MessageId}— повторная доставка после сбоя не меняет проекцию. - На бизнес-уровне: монотонный rank-guard (INV-CHAT-1) + поглощающий
Ready: событие, не продвигающее ранг, пропускается — защита от out-of-order и от повторного проигрывания.
Полный повторный проход топика с offset 0 безопасен: per-deal порядок партиции сохраняется, промежуточные
Failed/Provisioning перекрываются финальным Ready (поглощение), проекция сходится к терминальному
статусу чата. Runbook — docs/06-services/crm/runbooks/.
Retry и DLQ
DLQ-топик: pin.crm.deal-chat-status-dlq.v1 (envelope исходного события + заголовки x-dlq-reason,
x-dlq-error-code, x-source-topic, x-original-offset).
| Сбой | Код | Retry | DLQ | Ручное действие |
|---|---|---|---|---|
| Инфраструктурный (PostgreSQL/Kafka недоступны, timeout) | — (exception) | Бесконечный exponential backoff 1s→60s с паузой партиции | Нет | Runbook: восстановить зависимость |
| Сделка не найдена (аномалия/edge read-after-write) | CRM_DEAL_NOT_FOUND | 5 попыток, backoff 5s→10s→30s→1m→2m | Да, после исчерпания + алерт | Проверить существование/soft-delete сделки; replay из DLQ |
Tenant mismatch (payload.TenantId ≠ deal.TenantId) | CRM_CHAT_STATUS_TENANT_MISMATCH | Нет (non-retriable) | Да + алерт | Разбор аномалии messenger; данные не применять |
Неизвестный Status (unknown enum) | CRM_CHAT_STATUS_UNKNOWN | Нет | Да + алерт | Обновить маппинг (релиз) → replay |
| Устаревшее/непродвигающее событие (монотонность) | — | Нет | Нет | Не сбой: ack + метрика crm_chat_status_stale_total |
| Soft-deleted сделка | — | Нет | Нет | Skip + warn (статус чата неактуален) |
Наблюдаемость и безопасность
- Logs (structured):
consumed/skipped-duplicate/skipped-stale/applied/deal-not-found/tenant-mismatch/dead-letteredс полямиDealId,TenantId,ChatId,FromStatus,ToStatus,MessageId,payload.Version. PII нет (статус чата иChatId— Internal;FailureReasonне содержит персональных данных). - Metrics:
crm_chat_status_consume_duration_ms,crm_chat_status_applied_total{to_status},crm_chat_status_stale_total,crm_chat_status_duplicates_total,crm_chat_status_dlq_total,crm_chat_status_contract_mismatch_total,crm_messenger_consumer_lag_seconds. - Traces: span приёма события → span обработки → span обращения к хранилищу; корреляция из метаданных события.
- Alerts: лаг консюмера > 5 мин (SLO README); DLQ > 0; частота переходов в
Failed> 10/ч (алерт READMEChatStatus = Failed > 10/ч). - Dashboard / Runbook: Grafana
pin-crm; runbooksdocs/06-services/crm/runbooks/(будущий путь). - Безопасность: консюмер работает под system-actor; доступ к топику messenger — m2m ACL Kafka;
tenant mismatch → fail closed (
CRM_CHAT_STATUS_TENANT_MISMATCH, DLQ); проекция-запись ортогональна permission-модели сделки (system-writer, публичных мутацийChatStatusнет).
Acceptance Criteria
- AC-1 (продвижение ранга). Дано:
DealEntity.ChatStatus = Pending. Когда: событиеProvisioning. Тогда:ChatStatus = Provisioning,UpdatedAtобновлён, ack. - AC-2 (поглощение Ready). Дано:
ChatStatus = Ready. Когда: приходит запоздалоеProvisioning/Failed. Тогда: проекция не меняется,skipped-stale, инкрементcrm_chat_status_stale_total, ack. - AC-3 (осцилляция одного ранга). Дано:
ChatStatus = Provisioning. Когда: событиеFailed(тот же ранг, иное значение). Тогда:ChatStatus = Failedприменяется (штатный ретрай провизии), ack. - AC-4 (дубль). Дано: событие с уже обработанным
MessageId. Когда: повторная доставка. Тогда:skipped-duplicate, проекция без изменений, ack. - AC-5 (tenant mismatch). Дано:
payload.TenantId ≠ TenantIdсделки. Когда: событие. Тогда:CRM_CHAT_STATUS_TENANT_MISMATCH(non-retriable) → DLQ + алерт, проекция не применяется. - AC-6 (неизвестный статус). Дано:
Statusвне enumDealChatStatus. Когда: событие. Тогда:CRM_CHAT_STATUS_UNKNOWN→ DLQ + алерт. - AC-7 (сделка не найдена). Дано: сделки с
DealIdещё нет (окно read-after-write). Когда: событие. Тогда:CRM_DEAL_NOT_FOUND— ретраи по политике, после исчерпания DLQ + алерт. - AC-8 (replay). Дано: полный повторный проход топика с offset 0. Когда: обработка. Тогда: проекция
сходится к терминальному
Ready(промежуточныеFailed/Provisioningпоглощены), дублей мутаций нет.
Обратные ссылки
Автоссылки: где используется этот документ.
- События (3): События сервиса messenger: реестр топиков, Event Contract: pin.crm.deal-members-changed (DealMembersChangedEvent), Event Contract: pin.messenger.deal-chat-status-changed (DealChatStatusChangedEvent)
Связанные документы
- Целевой агрегат/поле проекции: domain/deal.md (
DealEntity.ChatStatus, enumDealChatStatus); источник провизии чата:DealCreatedEvent→ events/deal-created.md. - Контракт входного события: ../../messenger/events/deal-chat-status-changed.md
(producer
messenger); карта событий/консюмеров: ../README.md. - Шаблон: consumer-template.md.
- Решения: ADR-0055 (свой мини-мессенджер), ADR-0052 (tenancy).