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

Consumer: Promotion Boost (pin-news-promotion-boost)

Обработка покупок продвижения из контекста promotion: денормализует суммарный вес активных покупок в поле PromotionBoost поста ленты новостей. Введён по ADR-0060: продвижение вынесено в контекст promotion, а news стал консюмером буста для целей Post (симметрично catalogpromotion-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-сервис и триггер

ПолеЗначение
Статус / ownerdraft / PIN Engagement
Сервис/контекстnews
Обрабатываемое событиеPromotionPurchaseChangedKafkaEvent
Topic / keypin.promotion.purchase-changed.v1, key = PurchaseId (per-purchase порядок)
ContractVersion= 1; при несовпадении версии — warn-and-proceed (news_promotion_contract_mismatch_total)
Consumer grouppin-news-promotion-boost
Producerpromotion
Feature flagnews.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-purchase Version-guard (шаг 4).

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

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

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

Реплика PromotionContribution (news-owned, лёгкая; таблица news.promotion_contributions): PurchaseId

  • PostId (составной ключ), BoostWeight, Status, Version (guard), EndsAt. Проекция вкладов, событий не поднимает.

Алгоритм

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

СбойRetryDLQДействие
Инфраструктурный (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): монотонную Version per-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.

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

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

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