Consumer: Promotion Boost (pin-news-promotion-boost)
Обработка покупок продвижения из контекста promotion: денормализует суммарный вес активных покупок в
поле PromotionBoost поста ленты новостей. Введён по ADR-0060:
продвижение вынесено в контекст promotion, а news стал консюмером буста для целей Post
(симметрично catalog — promotion-boost.md — для
Project/PropertyObject).
Назначение
Consumer обрабатывает pin.promotion.purchase-changed.v1 и пересчитывает PromotionBoost затронутых
постов. Из payload берутся только цели с TargetKind = Post (цели Project/PropertyObject
игнорируются — их обрабатывает catalog). Consumer поддерживает лёгкую news-owned реплику вкладов
PromotionContribution (по покупке + цели-посту), из которой пересчитывает Post.PromotionBoost как
сумму BoostWeight активных вкладов; активация добавляет вклад, истечение/отмена его снимают.
PromotionBoost — вход терма Promotion ранжирования (post-ranking-model.md);
дом поля — post.md.
Собственных Kafka-событий consumer не публикует (денормализованное поле — конечная точка, ADR-0056).
Обновление PromotionBoost — это денормализованный ranking-вход: оно не поднимает
PostContentChangedEvent, не пишет PostVersion, не переиндексирует поисковую проекцию
(../search/posts.md) и не бампит доменную Post.Version (правится только
PromotionBoost).
Owning-сервис и триггер
| Поле | Значение |
|---|---|
| Статус / owner | draft / PIN Engagement |
| Сервис/контекст | news |
| Обрабатываемое событие | PromotionPurchaseChangedKafkaEvent |
| Topic / key | pin.promotion.purchase-changed.v1, key = PurchaseId (per-purchase порядок) |
| ContractVersion | = 1; при несовпадении версии — warn-and-proceed (news_promotion_contract_mismatch_total) |
| Consumer group | pin-news-promotion-boost |
| Producer | promotion |
| Feature flag | news.promotions.enabled — гейт обработки консюмером (буст не применяется, если выключен) |
| Эффект (событие → что меняется) | PromotionContribution (news.promotion_contributions) + Post.PromotionBoost |
Контекст события
- Из метаданных доступны:
MessageId(дедупликация),CreatedAt, версия,AggregateId = PurchaseId,AggregateType = "PromotionPurchase", payload. - Actor — system-actor; consumer работает вне tenant-scope (
PromotionBoost— платформенный ранжирующий вход, ADR-0052 п.3): пост резолвится поPostIdнапрямую, tenant-фильтр отключён.TenantIdсобытия — только для аудита/логов. Корреляция сквозного трейса восстанавливается из метаданных. - Ключ бизнес-идемпотентности:
news-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 = Post; TargetId → PostId вклада/поста; Project/PropertyObject игнорируются |
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 | Да | Аудит/логи (пост адресуется по PostId, не по tenant) |
Реплика PromotionContribution (news-owned, лёгкая; таблица news.promotion_contributions): PurchaseId
PostId(составной ключ),BoostWeight,Status,Version(guard),EndsAt. Проекция вкладов, событий не поднимает.
Алгоритм
- Контекст события. При
news.promotions.enabled = falseconsumer не подписан (топик копит события, replay безопасен). Восстановить метаданные; при несовпадении версии сContractVersion(1)— warn-and-proceed. Проверить бизнес-идемпотентность по ключуnews-promotion:purchase-changed:{MessageId}: дубль →skipped-duplicate, пропуск (ack). - Проверка входа.
PurchaseId != Guid.Empty;Version >= 1;BoostWeight1..100;Status∈ enum; хотя бы одна цельPost(иначе — событие целиком проProject/PropertyObject→ штатный no-op ack + метрикаnews_promotion_no_post_target_total). Существование поста как ошибка не проверяется: вклад сохраняется, пост подберёт его при ближайшем пересчёте. - Что читаем. Реплика вкладов покупки по
PurchaseId; затронутые посты — поPostIdцелейPost(система-scope, без tenant-фильтра). - Бизнес-правила и маппинг.
- Guard монотонности (per-purchase): применять только если
Versionсобытия>=сохранённой версии вклада (иначе устаревший снапшот/replay —news_promotion_stale_total, skip). - Upsert реплики: для каждой цели
Post—PromotionContribution(PurchaseId, PostId, BoostWeight, Status, Version); приStatus ∈ {Expired, Cancelled}вклад помечается неактивным (или удаляется — эквивалентно для суммы). - Пересчёт постов: для каждого затронутого поста
PromotionBoost = Σ BoostWeightактивных вкладов{PurchaseId*, PostId = post.Id}. Каскада (как проектный буст уcatalog) дляPostнет — цель плоская.
- Guard монотонности (per-purchase): применять только если
- Что меняется. Upsert вкладов по целям; изменившиеся посты — обновление только с новым
PromotionBoost; доменнаяVersionне бампится, доменные события не поднимаются. Идентичный результат → пост не помечается изменённым (экономия, не триггерит reindex). Конфликт с авторской правкой поста — оптимистическая блокировка (перечитать и повторить, до 3 раз). - Согласованность. Реплика вкладов +
PromotionBoostпостов + отметка идемпотентности фиксируются в одном согласованном изменении; offset подтверждается только после успешной фиксации. - Результат. Успех → ack. Retriable инфраструктурный сбой → retry; non-retriable (контракт) → DLQ + алерт. SLO лага < 1 мин (README) — лента показывает последний применённый снапшот буста.
Диаграмма
Идемпотентность и replay
- Consumer-level: дедупликация по
MessageId(news-promotion:purchase-changed:{MessageId}). - Business-level: per-purchase
Version-guard в репликеPromotionContribution. Key события =PurchaseId→ per-purchase порядок; guard закрывает replay и дубли. - Replay: да — реплика вкладов + детерминизм суммы делают пересчёт воспроизводимым. Нужен для
первичного наполнения после включения флага
news.promotions.enabledи после сброса поля/вкладов (переигрывание топика).
Retry и DLQ
DLQ-топик: pin.news.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 / news-overview |
| Конфликт оптимистической блокировки — гонка с авторской правкой поста | 3 немедленных повтора с перечиткой | После исчерпания | Расследование гонки |
Неизвестный ContractVersion/структура payload | Нет | Да + алерт | Согласовать контракт с promotion → replay |
Пост «не найден» (архивирован/удалён) — не ошибка: вклад остаётся в реплике, PromotionBoost применится
при появлении/восстановлении поста (пересчёт по курсору/replay).
Наблюдаемость и безопасность
- Logs:
consumed/skipped-duplicate/skipped-stale/recomputed/no-post-target/dead-lettered(PurchaseId,Version,postsAffected); PII нет. - Metrics:
news_promotion_lag_seconds(now − время события),news_promotion_consume_duration_ms,news_promotion_posts_recomputed_total,news_promotion_stale_total,news_promotion_no_post_target_total,news_promotion_retries_total,news_promotion_dlq_total,news_promotion_contract_mismatch_total. - Traces: span приёма события → span обработки → span обращения к хранилищу; correlation из метаданных.
- Alerts: лаг > 1 мин (SLO README); DLQ > 0; рост
contract_mismatch_total. Dashboard/Runbook: news-overview; runbooks «replay DLQ», «пересбросPromotionBoost». - Безопасность: system-actor; trusted-граница (внутренний Kafka, m2m ACL);
PromotionBoost— платформенный ranking-вход (обновление вне tenant-scope, ADR-0052 п.3); fail-closed на нарушении контракта (DLQ).
Кросс-сервисный контракт
newsожидает отpromotion(топикpin.promotion.purchase-changed.v1,ContractVersion = 1, key =PurchaseId): монотоннуюVersionper-purchase; полный набор целей покупки в каждом событии (Targets— снапшот текущего состояния, не дельта), чтобы пересчётΣ BoostWeightбыл воспроизводим по последнему событию;BoostWeightв диапазоне1..100;Statusиз известного enum (Active/Expired/Cancelled;Pendingв no-payment режиме не встречается). Истечение по времени фиксируется отдельным событием со сменойStatus = Expired(consumer не запускает таймеры сам).newsпредоставляет: денормализованное полеPost.PromotionBoostдля термаPromotionранжирования; исходящих событий нет (конечная точка, ADR-0056). Реплика вкладовPromotionContribution— внутренняя, наружу не публикуется.- Поведение при устаревании/отсутствии данных: пост-цель может отсутствовать (архивирован/не создан) —
вклад всё равно сохраняется,
PromotionBoostприменится при ближайшем пересчёте/восстановлении поста; событие сVersionменьше сохранённой — игнорируется (stale). Дрейф суммы устраняется replay-ем топика (детерминированный пересчёт из вкладов).
Предусловия и постусловия
- Предусловие:
news.promotions.enabled = true(иначе consumer не подписан, топик копит события); топик доступен;promotion— единственный писатель. - Постусловие (успех): для каждой цели
Postсобытия —PromotionContribution(PurchaseId, PostId)отражаетBoostWeight/Status/Versionсобытия;Post.PromotionBoostкаждого затронутого поста =Σ BoostWeightактивных вкладов; доменнаяPost.Versionне бампнута,PostVersion/search-проекция/PostContentChangedEventне затронуты; ключ идемпотентности зафиксирован, offset подтверждён.
Acceptance Criteria
- Активация буста. Дано пост
Pбез активных вкладов; когда приходитpurchase-changedс целью{TargetKind=Post, TargetId=P},Status=Active,BoostWeight=30; тогдаPromotionContributionсоздаётся активным иP.PromotionBoost = 30. - Сумма нескольких покупок. Дано у
Pактивный вклад30отPurchase1; когда приходитActive-вклад20отPurchase2; тогдаP.PromotionBoost = 50(Σактивных). - Истечение/отмена. Дано у
Pактивный вклад30; когда приходит то жеPurchaseIdсоStatus ∈ {Expired, Cancelled}; тогда вклад помечается неактивным иP.PromotionBoostпересчитывается без него (при единственном вкладе →0). - Событие без Post-цели. Дано событие только с целями
Project/PropertyObject; когда обрабатывается; тогда штатный no-op ack +news_promotion_no_post_target_total, посты не трогаются (цели каталога — не этот consumer). - Stale-версия. Дано вклад с
Version = 7; когда приходит событие того жеPurchaseIdсVersion = 6; тогда пропуск +news_promotion_stale_total, сумма не меняется. - Пост не найден. Дано
TargetId = P, постаPнет (архивирован/удалён); когда обрабатывается; тогда вклад сохраняется (не ошибка),PromotionBoostприменится при появлении/восстановленииP. - Идемпотентный повтор. Дано
MessageId = Mобработан; когдаMдоставлен повторно; тогдаskipped-duplicate, сумма и вклады не меняются. - Флаг выключен. Дано
news.promotions.enabled = false; когда покупки продвигаются вpromotion; тогдаnewsсобытия не обрабатывает (буст не применяется), при последующем включении флага replay восстанавливает корректныеPromotionBoost. - Гонка с авторской правкой. Дано пост одновременно правит автор; когда consumer пишет
PromotionBoostи ловит конфликт оптимистической блокировки; тогда до 3 перечиток-повторов, после исчерпания — DLQ.
Обратные ссылки
Автоссылки: где используется этот документ.
- Домен (CQRS) (2): Entity / Aggregate: Post, Entity / Aggregate: PostReaction + проекция PostRankingSignals (Design)
- scenarios (1): UC-OVR-009. Продвижение поста / объекта недвижимости
- index.md (1): News Service (Pin.News)
Связанные документы
- Целевое поле: post.md (
PromotionBoost); ранжирование — post-ranking-model.md (термPromotion= нормализованныйPromotionBoost). - Источник события: promotion/events/promotion-purchase-changed.md;
контекст-владелец —
promotion/README.md. - Симметричный потребитель для
Project/PropertyObject—catalogpromotion-boost.md; карта событий/консюмеров — ../events/README.md, ../README.md. - ADR-0052, ADR-0056, ADR-0060.