UC-PRM-003. Истечение срока покупки продвижения
Назначение
Описывает системный сценарий: фоновый polling-worker завершает истёкшие покупки продвижения
(Active → Expired) и публикует по каждой событие, по которому downstream-потребители снимают вклад
покупки из буста целей. Продвижение перестаёт влиять на ранжирование витрины в пределах интервала job-а.
Связан с контекстом promotion.
| Поле | Значение |
|---|
| ID | UC-PRM-003 |
| Контекст | promotion (см. docs/06-services/promotion/) |
| Тип | исключительный (фоновый) |
| Статус | draft |
Актор
| Поле | Значение |
|---|
| Роль | System (ExpirePromotionsJob, Pin.Promotion.Background) |
| Permissions / scope | System — 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 |
Основной Поток
- Тик цикла. Расписание запускает один цикл истечения:
AsOf = now() (UTC), верхняя граница BatchSize,
идентификатор запуска для корреляции. Внешнего ввода нет; бизнес-идемпотентность обеспечивается предикатом
статуса (шаг 2).
- Отбор кандидатов. Система отбирает
Active-покупки с EndsAt <= AsOf (терминальные
Expired/Cancelled не выбираются — INV-6), сортировка EndsAt asc (сначала самые старые), не более
BatchSize, вместе с целями покупок (нужны для снапшота события). Выбранные строки блокируются за одним
инстансом (FOR UPDATE SKIP LOCKED) и пропускаются, если залочены другим инстансом или in-flight
отменой POST /promotions/{id}/cancel.
- Переход. Для каждой покупки —
Active → Expired (guard INV-6); Version инкрементируется, UpdatedAt = AsOf. Пересчёта буста в промо-контексте нет — денормализация вынесена к потребителям (ADR-0060).
- Фиксация и событие. Переходы батча и события
PromotionPurchaseChangedKafkaEvent (Status = Expired)
фиксируются в одном согласованном изменении на батч; блокировки строк держатся до фиксации. Публикация в
pin.promotion.purchase-changed.v1 — после фиксации (at-least-once, key = PurchaseId).
- Реакция потребителей (асинхронно). По событию потребители снимают вклад покупки у себя:
catalog и
news пересчитывают буст целей без истёкшей покупки, notifications шлёт покупателю «срок истёк».
- Добор и метрики. Остаток 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):
promotion (job) публикует PromotionPurchaseChangedKafkaEvent (Status = Expired) в
pin.promotion.purchase-changed.v1, key = PurchaseId (per-purchase порядок), ContractVersion = 1.
catalog (PromotionBoostConsumer) берёт цели TargetKind ∈ {Project, PropertyObject}, помечает вклад
покупки неактивным и пересчитывает PromotionBoost затронутых карточек как сумму активных вкладов (для
Project — с каскадом на опубликованные объекты проекта). Следствие-событий не публикует (витрина —
конечная точка).
news (PromotionBoostConsumer) берёт цели TargetKind = Post, снимает вклад из Post.PromotionBoost.
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. Гонка с ручной отменой
- Покупка залочена отменой
POST /promotions/{id}/cancel → worker SKIP LOCKED её в этом цикле. Если
менеджер зафиксировал отмену между чтением и записью worker-а — конфликт по версии → перечитка (покупка
уже Cancelled, в выборку не попадёт по INV-6). Возврат к основному потоку на следующем цикле. Двойного
события по покупке нет.
A2. Poison-покупка
- Guard домена сработал при неожиданном состоянии → покупка исключается из батча,
ERROR-лог с
PurchaseId, остальной батч фиксируется (continue, не stop). Poison — на следующий цикл; при
систематическом повторе — алерт по errors_total, разбор по runbook.
A3. Параллельные инстансы worker-а
- Двойного перехода нет: каждая покупка достаётся ровно одному инстансу (
FOR UPDATE SKIP LOCKED), по ней
публикуется ровно одно событие Status = Expired.
A4. Инфраструктурный сбой в цикле
- Сбой PostgreSQL/шины → откат всего батча, блокировки строк освобождаются, событие не публикуется.
Ретрай — следующим циклом: те же покупки ещё
Active с EndsAt <= now → выберутся снова (идемпотентно).
A5. Пустой цикл
- Нет
Active-покупок с EndsAt <= AsOf → no-op success: переходов и событий нет.
A6. Дубликат события у потребителя
- Повторная доставка (at-least-once) или replay → потребитель отсекает по
MessageId (дедуп) и
Version-guard: состояние буста не мутируется повторно.
Постусловие
- Истёкшие покупки терминализованы в
Expired (Version++); по каждой опубликовано событие Status = Expired.
- Буст целей снят у потребителей (
catalog/news) в пределах интервала job-а + SLA потребителя; покупателю
ушло уведомление «срок истёк».
- Терминальные покупки не выбираются повторно (INV-6) — повторный цикл идемпотентен.
Возможные Ошибки
| Код | HTTP | Условие | Поведение клиента |
|---|
| — | — | job не имеет HTTP-контракта; инфраструктурный сбой → откат батча + ретрай следующим циклом; poison-покупка → исключение из батча + лог/алерт | — |
Отказоустойчивость и восстановление
| Сценарий | Правило |
|---|
| Повторный цикл / restart | Checkpoint не нужен: выборка отбирает только 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 |
|---|
PromotionPurchaseChangedKafkaEvent | pin.promotion.purchase-changed.v1 | Active → Expired | catalog, 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 потребителя) может показывать прежний
буст, после обработки — буст снят.
Обратные ссылки
Автоссылки: где используется этот документ.
Связанные Документы