Consumer: DevStatsFunnelBookingCreatedConsumer
Наполняет этап Bookings воронки funnel_daily: создание брони
(Резерв) → uniqState(BookingId) по срезу (иерархия × дата). Обогащение иерархией — из
dim_property_object по PropertyObjectId. Часть views → favorites → selections → leads → bookings → deals (ADR-0057).
Назначение
Consumer DevStatsFunnelBookingCreatedConsumer слушает событие booking-created (топик
pin.crm.booking-created.v1), по PropertyObjectId резолвит иерархию объекта недвижимости из
dim_property_object и добавляет частичную строку funnel_daily с заполненным состоянием этапа
Bookings; прочие этапы — пустые *State(). Запись — append-only в аналитическое хранилище
pin_dev_stats, не source of truth (брони — в crm).
Owning-сервис и триггер
| Поле | Значение |
|---|---|
| Сервис/контекст | dev-stats (DevStats.Background) |
| Consumer | DevStatsFunnelBookingCreatedConsumer |
| Обрабатываемое событие | booking-created |
| Topic | pin.crm.booking-created.v1 |
| ContractVersion | 1 (warn-and-proceed) |
| Consumer group | dev-stats-funnel-crm-booking |
| Producer | crm (операция create-booking → Reserved) |
| Эффект (событие → что меняется) | pin_dev_stats.funnel_daily, этап Bookings = uniqState(BookingId) |
| Feature flag | dev-stats.funnel.enabled |
Контекст события
- Из метаданных события доступны идентификатор сообщения (
MessageId), время события (CreatedAt) и идентификатор источника (AggregateId = BookingId). - Сквозной трейс восстанавливается из метаданных события.
- Ключ идемпотентности —
uniqState(BookingId)+ отсев повторногоMessageIdв батче.
Входные данные / payload события
Контракт: event: booking-created. Consumer читает:
| Поле payload | Тип | Обяз. | Как используется |
|---|---|---|---|
BookingId | Guid | Да | бизнес-ключ этапа → uniqState(BookingId) |
PropertyObjectId | Guid | Да | lookup dim_property_object → иерархия |
TenantId | Guid | Да | tenant-guard: должен = dim_property_object.DeveloperId (иначе fail-closed) |
CreatedAt | DateTimeOffset | Да | EventDate = toDate(CreatedAt) |
InitiatedBy/ProgramTerms/ReserveExpiresAt в воронке не используются.
Алгоритм
Консюмер батчевый: шаги 1–4 — на сообщение, шаги 5–6 — на батч (порог ≤ 1000 строк или 5 секунд; freshness SLA ≤ 5 мин).
- Проверить контекст события: сверить
ContractVersion(=1; при несовпадении — warn-and-proceed); идемпотентность поBookingId/MessageId— повтор пропускается (no-op). - Проверить payload (
BookingId/PropertyObjectId/TenantId/CreatedAt); при невалидном — пропуск с warning (skip + warning). - Обогащение иерархией: по
PropertyObjectIdвзять изdim_property_objectпоследнюю версию путиDeveloperId → … → LayoutId(argMax(…иерархия…, UpsertedAt), безFINAL); строки нет → отложенное повторное обогащение (deferred re-enrich, backoff, ≤ N попыток; при устойчивом промахе → DLQ + алерт «dimension gap»). Tenant-guard:dim_property_object.DeveloperIdдолжен совпадать сTenantIdсобытия; иначе — пропуск + alert (нарушение tenant), строка не пишется (fail-closed, без кросс-tenant утечки). - Собрать частичную строку
funnel_daily: ключ(DeveloperId, ProjectId, BuildingId, LayoutGroupId, LayoutId, PropertyObjectId, EventDate),Bookings = uniqState(BookingId), прочие этапы — пустые*State(). - Пакетно вставить строку в хранилище
funnel_daily(батч ≤ 1000 строк / 5 с);AggregatingMergeTreeсольёт частичные состояния по ключу. Смещение (offset) фиксируется только после подтверждённой вставки. - Записать метрики/логи; успех → ack.
Диаграмма
Идемпотентность и replay
- Business key
BookingId(uniqState) — повтор не растит счётчик; +MessageIdв батче. Replay с offset безопасен. Late-dimension → deferred re-enrich (какproperty_object_view_facts).
Retry и DLQ
| Сбой | Retry | DLQ | Ручное действие |
|---|---|---|---|
| Транзиент ClickHouse | Да (offset не закоммичен) | Нет | авто-догон |
Нет строки dim_property_object (late-dimension) | deferred re-enrich (backoff, ≤ N попыток) | Да при устойчивом промахе + алерт «dimension gap» | backfill dim_property_object, затем replay из DLQ |
TenantId ≠ dim_property_object.DeveloperId | Нет | Нет | skip + alert (нарушение tenant) |
| Битый payload | Нет | Нет | skip + warning |
Версия события ≠ ContractVersion | Не сбой: warning-лог | Нет | сверить миграцию контракта crm |
| Сбой между вставкой и commit offset | Повторная доставка (at-least-once) | Нет | не требуется — uniqState(BookingId) идемпотентен |
Наблюдаемость и безопасность
- Logs/metrics/traces — consume/enrich/ingest, счётчик tenant-mismatch. PII нет;
DeveloperIdклеймится изdim_property_objectи сверяется сTenantId(fail-closed). Воронка агрегатна.
Кросс-сервисные контракты
| Направление | Контракт |
|---|---|
Ожидает от crm | Событие booking-created (pin.crm.booking-created.v1, ContractVersion = 1, at-least-once) при создании брони (переход объекта в Reserved). Обязательные для воронки поля: BookingId (уникален и стабилен на бронь → идемпотентность этапа), PropertyObjectId, TenantId = DeveloperId, CreatedAt. Отмена/протухание брони этап Bookings не уменьшает (воронка кумулятивна по факту резерва; следующий этап — booking-paid). |
Ожидает от chessboard (через dim-ingest) | Строку dim_property_object с иерархией и DeveloperId; при отставании — late-dimension (park). |
| Предоставляет | Частичную строку funnel_daily: Bookings = uniqState(BookingId) по срезу (иерархия × EventDate). Потребитель — read API get-conversion-funnel (uniqMerge, без FINAL). Порядок прихода этапов роли не играет — AggregatingMergeTree сливает состояния по ключу. |
| Окно eventual-consistency | ≤ 5 мин. В промежутке конверсия bookings/leads по объекту занижена — догоняет после batch-вставки; при parked-событии — после backfill измерения. |
Предусловия и постусловия
- Предусловия: включён флаг
dev-stats.funnel.enabled; для объекта наполнена строкаdim_property_object; событие проходит валидацию payload и tenant-guard. - Постусловия: в
funnel_dailyпо срезу (иерархия объекта, деньCreatedAt) состояниеBookingsучитываетBookingIdровно один раз; offset группыdev-stats-funnel-crm-bookingпродвинут строго после подтверждённой вставки; операционные данныеcrmне изменены.
Acceptance Criteria
- Happy path: Дано — флаг включён,
dim_property_objectдляPO1есть,TenantId = dim.DeveloperId; Когда — приходитbooking-created(B1,PO1, деньD); Тогда —uniqMerge(Bookings)по срезуPO1 × Dувеличивается на 1. - Идемпотентность (повтор доставки): Дано —
B1учтён; Когда — повторная доставка того же события; Тогда —Bookingsне меняется, offset продвинут. - Tenant-guard: Дано —
TenantIdсобытия ≠dim_property_object.DeveloperId; Когда — обработка; Тогда — строка не пишется, skip + alert (нарушение tenant), кросс-tenant утечки нет. - Late-dimension: Дано — строки
dim_property_objectнет; Когда — событие обработано; Тогда — сообщение паркуется; после появления измерения — дозаполнение; при устойчивом промахе — DLQ + алерт. - Флаг выключен / битый payload: Дано —
funnel.enabled = offили отсутствуетPropertyObjectId; Когда — обработка; Тогда — этап не наполняется, событие ack/skip без искажения агрегатов. - Отказ хранилища: Дано — ClickHouse недоступен; Когда — вставка падает; Тогда — offset не коммитится, retry с backoff, догон без дублей.
Обратные ссылки
Автоссылки: где используется этот документ.
- События (2): События dev-stats, Event Contract: pin.crm.booking-created (BookingCreatedEvent)
- Консюмеры (3): Consumer: DevStatsFunnelBookingPaidConsumer, Consumer: DevStatsFunnelDealCreatedConsumer, Consumer: DevStatsFunnelLeadCreatedConsumer
Связанные документы
- Роллап: funnel-daily-rollup.md; измерение: dim_property_object.
- Контракт: crm/events/booking-created.md.
- Соседи: booking-paid.md, deal-created.md.
- ADR-0057, ADR-0056.