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

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-сервис и триггер

ПолеЗначение
Статус / ownerdraft / PIN Marketplace
Сервис/контекстcatalog
Обрабатываемое событиеPromotionPurchaseChanged
Topic / keypin.promotion.purchase-changed.v1, key = PurchaseId (per-purchase порядок)
ContractVersion= 1; при несовпадении версии события — warn-and-proceed (catalog_promotion_contract_mismatch_total)
Consumer grouppin-catalog-promotion-boost
Producerpromotion
Feature flagcatalog.promotions.enabled — гейт обработки (буст не применяется, если выключен)

Событие → эффект

СобытиеЭффект в витрине каталога
PromotionPurchaseChanged (Status = Active)Вклад покупки добавляется в реплику PromotionContribution; PromotionBoost затронутых карточек пересчитывается как сумма активных вкладов
PromotionPurchaseChanged (Status = Expired/Cancelled)Вклад снимается; PromotionBoost пересчитывается без него

Контекст события

  • Из метаданных события доступны: идентификатор сообщения (для дедупликации), PurchaseId (AggregateId), тип агрегата, время события. Корреляция сквозного трейса восстанавливается из метаданных.
  • Актор — системный; карточки не tenanted, TenantId события — только для аудита/логов.
  • Идемпотентность: ключ catalog-promotion:purchase-changed:{MessageId}; бизнес-идемпотентность — per-purchase Version-guard (шаг 4).

Входные данные / payload события

Контракт — event-contract: pin.promotion.purchase-changed.v1. Ниже — поля, которые consumer реально читает.

Поле payloadТипОбяз.Как используется (маппинг)
PurchaseIdGuidДаКлюч реплики PromotionContribution (+ цель); guard-агрегат
TargetsPromotionTargetModel[]ДаФильтр TargetKind ∈ {Project, PropertyObject}; TargetId → адресация вкладов/карточек; Post игнорируется
BoostWeightintДаВклад покупки в сумму (при Status=Active)
Statusstring (enum)ДаActive → вклад учитывается; Expired/Cancelled → снимается; Pending — не встречается в no-payment режиме
EndsAtDateTimeOffsetДаИнформационно (реплика вклада); истечение фиксирует job promotion отдельным событием
VersionlongДаМонотонный per-purchase guard (replay/дубли)
TenantIdGuidДаАудит/логи (карточки не tenanted)

Алгоритм

  1. Контекст события. При catalog.promotions.enabled = false consumer не подписан (топик копит события, replay безопасен). Восстановить метаданные; при несовпадении ContractVersion — warn-and-proceed; проверить идемпотентность по ключу catalog-promotion:purchase-changed:{MessageId} — дубль → пропуск (ack, no-op).
  2. Проверка входа. PurchaseId != Guid.Empty; Version >= 1; BoostWeight 1..100; Status ∈ enum; хотя бы одна цель Project/PropertyObject (иначе — событие целиком про Post → штатный no-op ack + метрика). Существование карточек по id не проверяется как ошибка: цель может ещё не доехать из chessboard — вклад сохраняется, карточка подберёт его при ближайшем пересчёте.
  3. Что читаем. Реплика вкладов покупки (по PurchaseId); затронутые карточки — по TargetId целей: карточка проекта, карточки объектов недвижимости из целей плюс опубликованные объекты проекта (проектный буст каскадит на объекты проекта).
  4. Бизнес-правила и переходы.
    • 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} (каскад проектного буста).
  5. Что меняется. Реплика вкладов (upsert по цели) и PromotionBoost изменившихся карточек. Идентичный результат → карточка не обновляется (экономия).
  6. Согласованность. Реплика вкладов + PromotionBoost карточек + отметка идемпотентности — в одном согласованном изменении; offset подтверждается после успешной фиксации. Конфликт оптимистической блокировки с другими консюмерами карточки → перечитать и повторить (до 3 раз).
  7. Результат. Успех → 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).

СбойRetryDLQДействие
Инфраструктурный (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 восстановлен детерминированно (реплика вкладов + сумма).

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

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

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