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

UC-PRM-003. Истечение срока покупки продвижения

Назначение

Описывает системный сценарий: фоновый polling-worker завершает истёкшие покупки продвижения (Active → Expired) и публикует по каждой событие, по которому downstream-потребители снимают вклад покупки из буста целей. Продвижение перестаёт влиять на ранжирование витрины в пределах интервала job-а. Связан с контекстом promotion.

Metadata

ПолеЗначение
IDUC-PRM-003
Контекстpromotion (см. docs/06-services/promotion/)
Типисключительный (фоновый)
Статусdraft

Актор

ПолеЗначение
РольSystem (ExpirePromotionsJob, Pin.Promotion.Background)
Permissions / scopeSystem — cross-tenant scan (ADR-0052 п.3), scope-фильтры отключены
Tenantне применяется (обрабатываются покупки всех компаний)
Record-levelне применяется

Предусловие

  • Существуют покупки PromotionPurchase в статусе Active с EndsAt <= now() (UTC).
  • Покупка в Active означает завершённую активацию без оплаты (PaymentStatus = NotRequired, решение №10); «зависших» Pending в режиме без оплаты нет (активация мгновенна).
  • Отмена по делистингу цели не входит в этот сценарий — цель живёт в чужом контексте, потребитель снимает вклад по факту делистинга у себя.

Триггер

ПолеЗначение
ИсточникSchedule (фиксированный интервал 1–5 мин, без внешнего планировщика — ADR-0056 п.3)
Триггерящий элементworker expire-promotions

Основной Поток

  1. Тик цикла. Расписание запускает один цикл истечения: AsOf = now() (UTC), верхняя граница BatchSize, идентификатор запуска для корреляции. Внешнего ввода нет; бизнес-идемпотентность обеспечивается предикатом статуса (шаг 2).
  2. Отбор кандидатов. Система отбирает Active-покупки с EndsAt <= AsOf (терминальные Expired/Cancelled не выбираются — INV-6), сортировка EndsAt asc (сначала самые старые), не более BatchSize, вместе с целями покупок (нужны для снапшота события). Выбранные строки блокируются за одним инстансом (FOR UPDATE SKIP LOCKED) и пропускаются, если залочены другим инстансом или in-flight отменой POST /promotions/{id}/cancel.
  3. Переход. Для каждой покупки — Active → Expired (guard INV-6); Version инкрементируется, UpdatedAt = AsOf. Пересчёта буста в промо-контексте нет — денормализация вынесена к потребителям (ADR-0060).
  4. Фиксация и событие. Переходы батча и события PromotionPurchaseChangedKafkaEvent (Status = Expired) фиксируются в одном согласованном изменении на батч; блокировки строк держатся до фиксации. Публикация в pin.promotion.purchase-changed.v1 — после фиксации (at-least-once, key = PurchaseId).
  5. Реакция потребителей (асинхронно). По событию потребители снимают вклад покупки у себя: catalog и news пересчитывают буст целей без истёкшей покупки, notifications шлёт покупателю «срок истёк».
  6. Добор и метрики. Остаток backlog добирается следующими циклами (EndsAt asc); счётчики (ExpiredCount, SkippedLocked, DurationMs) уходят в метрики/логи (HTTP-ответа у job-а нет).

Инварианты и переход статуса

АспектПравило
INV-6 TerminalFrozenиз Expired/Cancelled переходов нет; терминальные покупки в выборку не попадают (идемпотентность повторного цикла)
ПереходActive → Expired, условие now() >= EndsAt, инициатор — System (ExpirePromotionsJob); эффект — вклад покупки подлежит снятию у потребителей
Guard монотонностипо каждой покупке Version монотонно растёт; потребители применяют событие только если его Version >= Version сохранённого вклада (защита от replay/переупорядочивания)

Межсервисные последовательности и контракты

Событийная цепочка (все звенья после публикации — асинхронные, at-least-once):

  1. promotion (job) публикует PromotionPurchaseChangedKafkaEvent (Status = Expired) в pin.promotion.purchase-changed.v1, key = PurchaseId (per-purchase порядок), ContractVersion = 1.
  2. catalog (PromotionBoostConsumer) берёт цели TargetKind ∈ {Project, PropertyObject}, помечает вклад покупки неактивным и пересчитывает PromotionBoost затронутых карточек как сумму активных вкладов (для Project — с каскадом на опубликованные объекты проекта). Следствие-событий не публикует (витрина — конечная точка).
  3. news (PromotionBoostConsumer) берёт цели TargetKind = Post, снимает вклад из Post.PromotionBoost.
  4. notifications (PromotionPurchaseChangedConsumer) шлёт менеджеру компании уведомление «срок истёк».

Что промо-контекст ПРЕДОСТАВЛЯЕТ (исходящий контракт события): поля PurchaseId, TenantId, PackageId, PackageCode, PackageKind, Targets[] {TargetKind, TargetId}, Status = Expired, PaymentStatus, BoostWeight, StartsAt, EndsAt, Price {Amount, CurrencyId, Currency: CurrencySnapshot}, Version; ключ партиционирования — PurchaseId; версия — ContractVersion = 1; гарантия порядка — per-purchase по ключу; PII событие не несёт.

Что промо-контекст ОЖИДАЕТ от соседей: ничего синхронного. Цели — логические ссылки (без FK), снятие вклада и его идемпотентность — ответственность потребителя (MessageId + Version-guard). Отсутствие/ устаревание реплик у потребителя (напр. карточка ещё не доехала из chessboard) не блокирует job — вклад снимается из реплики потребителя, а не из промо-контекста.

Окно eventual-consistency и промежуточное состояние: между фиксацией Expired и обработкой события буст целей на витрине держится ещё до момента пересчёта у потребителя (SLA catalog/news < 1 мин, уведомление < 5 мин). В этом окне пользователь может краткий промежуток видеть цель в прежнем (забустованном) порядке ранжирования, хотя покупка уже терминальна.

Альтернативные Потоки

A1. Гонка с ручной отменой

  1. Покупка залочена отменой POST /promotions/{id}/cancel → worker SKIP LOCKED её в этом цикле. Если менеджер зафиксировал отмену между чтением и записью worker-а — конфликт по версии → перечитка (покупка уже Cancelled, в выборку не попадёт по INV-6). Возврат к основному потоку на следующем цикле. Двойного события по покупке нет.

A2. Poison-покупка

  1. Guard домена сработал при неожиданном состоянии → покупка исключается из батча, ERROR-лог с PurchaseId, остальной батч фиксируется (continue, не stop). Poison — на следующий цикл; при систематическом повторе — алерт по errors_total, разбор по runbook.

A3. Параллельные инстансы worker-а

  1. Двойного перехода нет: каждая покупка достаётся ровно одному инстансу (FOR UPDATE SKIP LOCKED), по ней публикуется ровно одно событие Status = Expired.

A4. Инфраструктурный сбой в цикле

  1. Сбой PostgreSQL/шины → откат всего батча, блокировки строк освобождаются, событие не публикуется. Ретрай — следующим циклом: те же покупки ещё Active с EndsAt <= now → выберутся снова (идемпотентно).

A5. Пустой цикл

  1. Нет Active-покупок с EndsAt <= AsOf → no-op success: переходов и событий нет.

A6. Дубликат события у потребителя

  1. Повторная доставка (at-least-once) или replay → потребитель отсекает по MessageId (дедуп) и Version-guard: состояние буста не мутируется повторно.

Постусловие

  • Истёкшие покупки терминализованы в Expired (Version++); по каждой опубликовано событие Status = Expired.
  • Буст целей снят у потребителей (catalog/news) в пределах интервала job-а + SLA потребителя; покупателю ушло уведомление «срок истёк».
  • Терминальные покупки не выбираются повторно (INV-6) — повторный цикл идемпотентен.

Возможные Ошибки

КодHTTPУсловиеПоведение клиента
job не имеет HTTP-контракта; инфраструктурный сбой → откат батча + ретрай следующим циклом; poison-покупка → исключение из батча + лог/алерт

Отказоустойчивость и восстановление

СценарийПравило
Повторный цикл / restartCheckpoint не нужен: выборка отбирает только Active, терминальный переход делает покупку невыбираемой (INV-6) → повтор безопасен
Параллельные инстансыFOR UPDATE SKIP LOCKED — покупка достаётся одному инстансу; двойного события нет
Гонка worker ↔ отмена менеджеромСтрока под отменой залочена → worker SKIP LOCKED; при конфликте версии проигравший перечитывает (покупка уже Cancelled)
Инфраструктурный сбойОткат батча, блокировки сняты; следующий цикл повторяет те же кандидаты
Poison-покупкаИсключается из батча, ERROR-лог; батч фиксируется; при систематике — алерт, runbook
Доставка событияat-least-once + потребительская дедупликация (MessageId) и Version-guard — повторная доставка не задваивает снятие буста
КомпенсацияНе требуется: снятие буста — идемпотентная реакция потребителя на терминальное состояние, обратного эффекта нет

Доменные События

СобытиеКогда поднимаетсяSubscribers
пересчёта буста в промо-контексте нет (у консюмеров, ADR-0060)

Kafka Интеграционные События

СобытиеTopicКогда публикуетсяConsumers
PromotionPurchaseChangedKafkaEventpin.promotion.purchase-changed.v1Active → Expiredcatalog, news (снятие буста), notifications

Наблюдаемость и НФТ

  • Метрики: promotion_expire_run_total (циклы, liveness), promotion_expire_cycle_duration_seconds, promotion_expire_items_total{outcome="expired"}, promotion_expire_backlog (gauge — Active c EndsAt < now, ещё не обработанные), promotion_expire_skip_locked_total, promotion_expire_errors_total, лаг очереди публикации pin.promotion.purchase-changed.v1.
  • Логи: cycle started/completed (счётчики, DurationMs), promotion expired (PurchaseId, TenantId, PackageKind), item failed (PurchaseId, исключение). PII нет.
  • SLO точности: покупка с EndsAt = t истекает не позже t + Interval + DurationCycle; событие снятия буста уходит в пределах одного интервала, потребитель применяет его в своём SLA (catalog/news < 1 мин).
  • Алерты: job не отработал > 2 интервалов (нет прироста promotion_expire_run_total); backlog > 100 в течение 5 мин; устойчивый рост errors_total; лаг публикации топика > 1000.
  • Нагрузка: целевой объём активных покупок 1–10k (ADR-0056); цель < 30 с/цикл, BatchSize по умолчанию 200, backlog добирается следующими циклами.
  • Доступ: System cross-tenant scan (внешнего ввода нет); публикация — trusted внутренний Kafka (m2m ACL).

Acceptance Criteria

  • AC1. Истечение и событие. Дано Active-покупка с EndsAt <= now. Когда отрабатывает цикл. Тогда она переходит в Expired (Version++), публикуется PromotionPurchaseChangedKafkaEvent (Status = Expired), и потребитель снимает вклад из буста цели.
  • AC2. Параллелизм без дублей. Дано несколько инстансов worker-а. Когда они выбирают кандидатов. Тогда каждая покупка обработана один раз (FOR UPDATE SKIP LOCKED), по ней ровно одно событие.
  • AC3. Идемпотентность повторного цикла. Дано покупка уже Expired. Когда запускается следующий цикл. Тогда она не выбирается (INV-6), новых переходов/событий по ней нет.
  • AC4. Гонка с ручной отменой. Дано отмена POST /promotions/{id}/cancel в полёте. Когда идёт цикл. Тогда worker не завершает уже терминальную покупку: она либо вне выборки, либо конфликт по версии → перечитка (Cancelled).
  • AC5. Пустой цикл. Дано нет Active-покупок с EndsAt <= now. Когда отрабатывает цикл. Тогда no-op: переходов и событий нет.
  • AC6. Частичный отказ. Дано poison-покупка в батче. Когда идёт цикл. Тогда она исключается и логируется, остальной батч фиксируется, poison — на следующий цикл.
  • AC7. Восстановление после сбоя. Дано инфраструктурный сбой при фиксации. Когда цикл падает. Тогда батч откатывается целиком, блокировки сняты, следующий цикл повторяет тех же кандидатов (событие не задвоено).
  • AC8. Окно eventual-consistency. Дано покупка только что стала Expired. Когда потребитель ещё не обработал событие. Тогда витрина краткий промежуток (в пределах SLA потребителя) может показывать прежний буст, после обработки — буст снят.

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

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

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