Consumer: Promotion Boost (pin-catalog-promotion-boost)
Kafka-consumer покупок продвижения из контекста promotion: денормализует суммарный вес активных
покупок в поле PromotionBoost витринных карточек проектов и объектов недвижимости. Введён по
ADR-0060: продвижение вынесено в контекст
promotion, а catalog из владельца стал консюмером буста (симметрично — news для Post).
Назначение
Consumer обрабатывает событие PromotionPurchaseChanged (pin.promotion.purchase-changed.v1) и
пересчитывает PromotionBoost затронутых карточек проекта/объекта недвижимости. Из payload берутся
только цели с TargetKind ∈ {Project, PropertyObject} (цели Post игнорируются — их обрабатывает
news). Consumer поддерживает лёгкую catalog-owned реплику вкладов PromotionContribution (по
покупке + цели), из которой пересчитывает PromotionBoost карточки как сумму BoostWeight активных
вкладов; активация добавляет вклад, истечение/отмена его снимают. PromotionBoost — первый ключ
дефолтной сортировки «Рекомендованные» (ranking-rule.md).
Собственных интеграционных событий consumer не публикует (витрина — конечная точка). Буст не влияет на видимость карточки (INV-1) и не участвует в ценовых агрегатах проекта.
Owning-сервис и триггер
| Поле | Значение |
|---|---|
| Статус / owner | draft / PIN Marketplace |
| Сервис/контекст | catalog |
| Обрабатываемое событие | PromotionPurchaseChanged |
| Topic / key | pin.promotion.purchase-changed.v1, key = PurchaseId (per-purchase порядок) |
| ContractVersion | = 1; при несовпадении версии события — warn-and-proceed (catalog_promotion_contract_mismatch_total) |
| Consumer group | pin-catalog-promotion-boost |
| Producer | promotion |
| Feature flag | catalog.promotions.enabled — гейт обработки (буст не применяется, если выключен) |
Событие → эффект
| Событие | Эффект в витрине каталога |
|---|---|
PromotionPurchaseChanged (Status = Active) | Вклад покупки добавляется в реплику PromotionContribution; PromotionBoost затронутых карточек пересчитывается как сумма активных вкладов |
PromotionPurchaseChanged (Status = Expired/Cancelled) | Вклад снимается; PromotionBoost пересчитывается без него |
Контекст события
- Из метаданных события доступны: идентификатор сообщения (для дедупликации),
PurchaseId(AggregateId), тип агрегата, время события. Корреляция сквозного трейса восстанавливается из метаданных. - Актор — системный; карточки не tenanted,
TenantIdсобытия — только для аудита/логов. - Идемпотентность: ключ
catalog-promotion:purchase-changed:{MessageId}; бизнес-идемпотентность — per-purchaseVersion-guard (шаг 4).
Входные данные / payload события
Контракт — event-contract: pin.promotion.purchase-changed.v1.
Ниже — поля, которые consumer реально читает.
| Поле payload | Тип | Обяз. | Как используется (маппинг) |
|---|---|---|---|
PurchaseId | Guid | Да | Ключ реплики PromotionContribution (+ цель); guard-агрегат |
Targets | PromotionTargetModel[] | Да | Фильтр TargetKind ∈ {Project, PropertyObject}; TargetId → адресация вкладов/карточек; Post игнорируется |
BoostWeight | int | Да | Вклад покупки в сумму (при Status=Active) |
Status | string (enum) | Да | Active → вклад учитывается; Expired/Cancelled → снимается; Pending — не встречается в no-payment режиме |
EndsAt | DateTimeOffset | Да | Информационно (реплика вклада); истечение фиксирует job promotion отдельным событием |
Version | long | Да | Монотонный per-purchase guard (replay/дубли) |
TenantId | Guid | Да | Аудит/логи (карточки не tenanted) |
Алгоритм
- Контекст события. При
catalog.promotions.enabled = falseconsumer не подписан (топик копит события, replay безопасен). Восстановить метаданные; при несовпаденииContractVersion— warn-and-proceed; проверить идемпотентность по ключуcatalog-promotion:purchase-changed:{MessageId}— дубль → пропуск (ack, no-op). - Проверка входа.
PurchaseId != Guid.Empty;Version >= 1;BoostWeight1..100;Status∈ enum; хотя бы одна цельProject/PropertyObject(иначе — событие целиком проPost→ штатный no-op ack + метрика). Существование карточек по id не проверяется как ошибка: цель может ещё не доехать изchessboard— вклад сохраняется, карточка подберёт его при ближайшем пересчёте. - Что читаем. Реплика вкладов покупки (по
PurchaseId); затронутые карточки — поTargetIdцелей: карточка проекта, карточки объектов недвижимости из целей плюс опубликованные объекты проекта (проектный буст каскадит на объекты проекта). - Бизнес-правила и переходы.
- Guard монотонности (per-purchase): применять только если
Version >= Versionсохранённого вклада (иначе устаревший снапшот/replay —catalog_promotion_stale_total, skip). - Обновление реплики: для каждой цели
Project/PropertyObject— вкладPromotionContribution(PurchaseId,TargetKind,TargetId,BoostWeight,Status,Version); приStatus ∈ {Expired, Cancelled}вклад помечается неактивным (эквивалентно исключению из суммы). - Пересчёт карточек: для каждой затронутой карточки
PromotionBoost = Σ BoostWeightактивных вкладов, адресующих карточку: карточка проекта — вклады{Project, card.Id}; карточка объекта —{PropertyObject, card.Id}плюс{Project, card.ProjectId}(каскад проектного буста).
- Guard монотонности (per-purchase): применять только если
- Что меняется. Реплика вкладов (upsert по цели) и
PromotionBoostизменившихся карточек. Идентичный результат → карточка не обновляется (экономия). - Согласованность. Реплика вкладов +
PromotionBoostкарточек + отметка идемпотентности — в одном согласованном изменении; offset подтверждается после успешной фиксации. Конфликт оптимистической блокировки с другими консюмерами карточки → перечитать и повторить (до 3 раз). - Результат. Успех → ack. Retriable инфраструктурный сбой → retry; non-retriable (контракт) → DLQ + алерт. SLO лага < 1 мин — витрина показывает последний применённый снапшот. Доменных/ интеграционных событий consumer не порождает (буст не влияет на INV-1 и агрегаты проекта).
Диаграмма
Идемпотентность и replay
- Дедупликация: ключ
catalog-promotion:purchase-changed:{MessageId}— повторная доставка не мутирует состояние. - Бизнес-guard: per-purchase
Version-guard в репликеPromotionContribution. Key события =PurchaseId→ per-purchase порядок; guard закрывает replay и дубли. - Replay: да — реплика вкладов + детерминизм суммы делают пересчёт воспроизводимым. Нужен для
первичного наполнения после включения флага и после
POST /admin/projections/rebuild(сбросPromotionBoost/вкладов и переигрывание топика).
Retry и DLQ
DLQ-топик: pin.catalog.promotion-boost-dlq.v1 (событие + заголовки x-dlq-reason, x-dlq-error-code,
x-source-topic, x-original-offset).
| Сбой | Retry | DLQ | Действие |
|---|---|---|---|
| Инфраструктурный (PostgreSQL/Kafka, timeout) | Бесконечный backoff 1s→60s с паузой партиции | Нет | Runbook promotion-overview / catalog-overview |
| Конфликт оптимистической блокировки — гонка с другим консюмером карточки | 3 немедленных повтора с перечиткой | После исчерпания | Расследование гонки |
Неизвестный ContractVersion/структура payload | Нет | Да + алерт | Согласовать контракт с promotion → replay |
Карточка «не найдена» — не ошибка: вклад сохраняется в реплике, карточка подберёт буст при появлении
из chessboard (пересчёт по курсору/rebuild).
Наблюдаемость и безопасность
- Logs:
consumed/skipped-duplicate/skipped-stale/recomputed/dead-lettered(PurchaseId,Version,cardsAffected); PII нет. - Metrics:
catalog_promotion_lag_seconds,catalog_promotion_consume_duration_ms,catalog_promotion_cards_recomputed_total,catalog_promotion_stale_total,catalog_promotion_retries_total,catalog_promotion_dlq_total,catalog_promotion_contract_mismatch_total. - Traces: span приёма события → span обработки → spans обращений к хранилищу; correlation из метаданных.
- Alerts: лаг > 1 мин (SLO README каталога); DLQ > 0; рост
contract_mismatch_total. Dashboard/Runbook: catalog-overview; runbooks «replay DLQ», «rebuild проекций». - Безопасность: системный актор; входная граница — trusted (внутренний Kafka, m2m ACL); карточки не tenanted; fail-closed на нарушении контракта (DLQ).
Acceptance Criteria
- Активация буста (объект недвижимости). Дано
PromotionPurchaseChanged(Status=Active, цельPropertyObject,BoostWeight=50). Когда обрабатывается. Тогда вклад добавлен в репликуPromotionContribution,PromotionBoostкарточки объекта = сумма активных вкладов (включая каскад проектного вклада); ack. - Каскад проектного буста. Дано цель
Project. Когда обрабатывается. ТогдаPromotionBoostкарточки проекта и всех опубликованных объектов недвижимости проекта пересчитан. - Снятие буста. Дано
Status = Expired/Cancelled. Когда обрабатывается. Тогда вклад помечен неактивным,PromotionBoostзатронутых карточек пересчитан без него. - Только
Post-цели. Дано событие с целями толькоTargetKind = Post. Когда обрабатывается. Тогда штатный no-op ack + метрика (эти цели обрабатываетnews). - Карточка ещё не доехала. Дано целевой карточки нет (лаг
chessboard). Когда обрабатывается. Тогда вклад сохранён в реплике (не ошибка), карточка подберёт буст при появлении/ближайшем пересчёте. - Stale / дубль. Дано
Versionниже сохранённого вклада либо повторMessageId. Когда доставлен. Тогдаskipped-stale/skipped-duplicate, no-op, ack. - Флаг выключен. Дано
catalog.promotions.enabled = false. Тогда consumer не подписан, топик копит события, последующий replay безопасен. - Инвариант невмешательства. Дано применён буст. Тогда
Status/видимость карточки (INV-1) и ценовые агрегаты проекта (MinPrice/PropertyObjectsAvailable/Payback) НЕ меняются. - Replay. Дано re-read топика после
POST /admin/projections/rebuild. Когда переигрывается. ТогдаPromotionBoostвосстановлен детерминированно (реплика вкладов + сумма).
Обратные ссылки
Автоссылки: где используется этот документ.
- Домен (CQRS) (4): Entity / Aggregate: CatalogProjectCard, Entity / Aggregate: CatalogPropertyObjectCard, Entity / Aggregate: DeveloperRankingRule, Entity / Aggregate: PromotionPurchase
- API (5): Catalog Developers API — реестр методов и общие правила области, Catalog Public API — реестр методов и общие правила, GET /api/v1/lots — Лента объектов недвижимости, POST /api/admin/v1/projections/rebuild — Принудительный пересчёт карточек по id (admin), POST /api/developers/v1/promotions/{purchaseId}/cancel — Отмена покупки продвижения
- Use Cases (3): UC-CTL-001. Лента проектов (главная страница, бесконечная лента 20–30 карточек), UC-CTL-004. Сортировка проектов (цена / дата добавления / популярность / окупаемость), UC-PRM-003. Истечение срока покупки продвижения
- События (1): Event Contract: pin.promotion.purchase-changed (PromotionPurchaseChangedKafkaEvent)
- Консюмеры (2): Consumer: Chessboard Projection Sync (pin-catalog-chessboard-sync), Consumer: Promotion Boost (pin-news-promotion-boost)
- Фоновые задачи (3): Background Job: DelistedRetentionJob, Background Job: ExpirePromotionsJob, Background Job: PopularityRecomputeJob
- scenarios (1): UC-OVR-009. Продвижение поста / объекта недвижимости
- index.md (2): Catalog Service (Pin.Catalog), Promotion Service (Pin.Promotion)
Связанные документы
- Целевое поле и формула: catalog-project-card.md,
catalog-property-object-card.md (
PromotionBoost); сортировка «Рекомендованные» — ranking-rule.md. - Источник события: promotion/events/promotion-purchase-changed.md;
агрегат — promotion/domain/promotion-purchase.md;
контекст-владелец —
promotion/README.md. - Симметричный потребитель для
Post—news(ADR-0060, post.md). - ADR-0052, ADR-0056, ADR-0060.