Background Job: ExpirePromotionsJob
Назначение
ExpirePromotionsJob — фоновый polling-worker контекста promotion (Pin.Promotion.Background), который
завершает истёкшие покупки продвижения. Обрабатывает одну фазу над агрегатом
PromotionPurchase: Status = Active при EndsAt <= now() → Expired
(stateDiagram и INV-6 в promotion-purchase.md).
Переход терминален и в одном согласованном изменении публикует
PromotionPurchaseChangedKafkaEvent (Status = Expired) в топик pin.promotion.purchase-changed.v1.
Денормализацию буста снимают потребители (catalog/news) по этому событию — сам промо-контекст
карточки не пересчитывает (ADR-0060). Механизм цикла — polling без Redis/внешнего планировщика
(ADR-0056 п.3): выборка кандидатов с блокировкой строк (FOR UPDATE SKIP LOCKED), переход и публикация
события — атомарно.
| Поле | Значение |
|---|
| Owner | PIN Marketplace |
| Статус документа | draft |
| Область действия | worker expire-promotions, таблица promotion.promotion_purchases, топик pin.promotion.purchase-changed.v1 |
| Assumptions / ограничения | целевой объём активных покупок 1–10k (ADR-0056); оплата отложена (решение №10) — истекают покупки в любом PaymentStatus; обрабатываются только Active (в режиме без оплаты Pending мгновенно активируется, «зависших» Pending нет); отмена по делистингу цели не в этом job-е — цель в чужом контексте, потребитель снимает вклад по факту делистинга у себя |
| Зависимости | PostgreSQL (promotion.promotion_purchases), Kafka (публикация события) |
Классификация
| Поле | Значение |
|---|
| Сервис | promotion |
| Тип job | Recurring (polling-worker) |
| Trigger | фиксированный интервал 1–5 мин, без внешнего планировщика (ADR-0056 п.3) |
| Актор | System (cross-tenant scan; внешнего ввода нет) |
| Критичность | tier-2 (ранжирование витрины у потребителей; ошибка → устаревший буст, не потеря данных — покупки терминализуются идемпотентно) |
Расписание И Locking
| Аспект | Правило |
|---|
| Schedule | фиксированный интервал (default 60 с, диапазон 1–5 мин, UTC); следующий тик не стартует, пока идёт предыдущий цикл |
| Distributed lock | нет глобального lock (ADR-0056 — без Redis); параллельные инстансы координируются row-level FOR UPDATE SKIP LOCKED в выборке — каждая строка достаётся ровно одному инстансу |
| Batch size | 200 покупок/цикл (конфигурируется); backlog добирается следующими циклами (order by EndsAt asc) |
| Max duration | цель < 30 с/цикл; при превышении — тик пропускается, добор в следующем цикле |
| Cancellation | при остановке worker-а текущий batch докоммичивается или откатывается целиком, блокировки строк освобождаются |
| Поле | Тип | Обязательность | Источник | Описание |
|---|
AsOf | DateTimeOffset | Да | системные часы (UTC) | Момент цикла для сравнения с EndsAt. |
BatchSize | int | Да | конфигурация (default 200) | Верхняя граница покупок за цикл. |
TenantId | Guid? | Нет | не применяется | Cross-tenant scan: платформенный System-worker без tenant-фильтра (ADR-0052 п.3), обрабатывает покупки всех компаний. |
Алгоритм
Каждый тик расписания запускает один цикл истечения (актор — System):
- Контекст цикла. Актор — System; cross-tenant разрешён явно (обрабатываются покупки всех компаний);
correlationId = идентификатор запуска цикла; AsOf = текущее системное время (UTC). Внешнего ввода
нет; бизнес-идемпотентность обеспечивается предикатом статуса (шаг 2).
- Отбор кандидатов. Отобрать покупки в статусе
Status = Active c EndsAt <= AsOf (INV-6 —
терминальные Expired/Cancelled не выбираются), сортировка EndsAt asc (сначала самые старые), не
более BatchSize за цикл; цели покупок нужны для снапшота события. Выбранные строки блокируются
(FOR UPDATE SKIP LOCKED) до конца цикла и пропускаются, если залочены другим инстансом или
in-flight отменой POST /promotions/{id}/cancel.
- Переход. Для каждой покупки —
Active → Expired (guard INV-6): Status ← Expired, UpdatedAt ← AsOf, Version += 1. Пересчёта буста в промо-контексте нет (денормализация — у потребителей, ADR-0060).
- Сохранение и событие. Переходы батча и события
PromotionPurchaseChangedKafkaEvent (топик
pin.promotion.purchase-changed.v1, key = PurchaseId, Status = Expired) фиксируются в одном
согласованном изменении на батч; блокировки строк держатся до фиксации. Дом контракта события —
promotion-purchase-changed.md (PII не несёт). Poison-покупка
(неожиданное состояние) исключается из батча, логируется и остаётся на следующий цикл; остальной батч
фиксируется (continue, не stop).
- Результат. Счётчики
ExpiredCount, SkippedLockedCount, DurationMs — для метрик/логов (не
HTTP-ответ). Инфраструктурная ошибка → откат батча, освобождение блокировок; ретрай — следующим циклом
(те же строки ещё Active → выберутся снова, идемпотентно).
Постусловие цикла. После успешного цикла: каждая отобранная покупка — терминальный Expired (Version
инкрементирован, UpdatedAt = AsOf) и в выборку больше не попадает (INV-6); по каждой — ровно одно событие
Status = Expired поставлено в очередь публикации (at-least-once); backlog promotion_expire_backlog
уменьшился на ExpiredCount. Денормализованный буст истёкших целей снимают потребители catalog/news
асинхронно по событию — окно eventual-consistency < 1 мин; в этом окне карточка/пост ещё может показывать
прежний вклад. Покупки, залоченные другим инстансом или in-flight отменой, остаются Active до следующего
цикла (не теряются). Частичный/прерванный прогон не нарушает инвариантов: незакоммиченные строки остаются
Active и добираются позже.
Граница Транзакции
| Элемент | Правило |
|---|
| Единица работы | на батч; блокировки строк (FOR UPDATE) живут до фиксации |
| Сохраняемое состояние | переходы PromotionPurchase (Status/UpdatedAt/Version) + событие PromotionPurchaseChangedKafkaEvent |
| External calls | Kafka-публикация — после фиксации (at-least-once); внутри цикла внешних синхронных вызовов нет |
| Частичная ошибка | continue — poison-покупка исключается и логируется, остальной батч фиксируется; инфраструктурный сбой — откат всего батча, ретрай следующим циклом |
Идемпотентность И Retry
| Сценарий | Правило |
|---|
| Job restart / повторный цикл | Checkpoint не нужен: выборка отбирает только Active, зафиксированный переход делает покупку терминальной (Expired) и невыбираемой (INV-6) → повтор безопасен |
| Duplicate item (параллельные инстансы) | FOR UPDATE SKIP LOCKED — покупка достаётся одному инстансу; двойного события нет |
Гонка worker ↔ POST /promotions/{id}/cancel | Строка под отменой менеджера залочена → worker её SKIP LOCKED в этом цикле; если менеджер зафиксировал отмену между чтением и записью worker-а — конфликт по версии, проигравший перечитывает (покупка уже Cancelled → в выборку не попадёт) |
| Повторяемая ошибка (инфраструктура) | Откат батча, блокировки сняты; следующий цикл повторяет — те же кандидаты ещё Active |
| Poison item | Исключается из батча, ERROR-лог с PurchaseId; не блокирует батч; при систематическом повторе — алерт по errors_total, разбор по runbook |
Наблюдаемость
| Тип | Правило |
|---|
| Logs | cycle started (runId, AsOf); batch selected (count); promotion expired (PurchaseId, TenantId, PackageKind, targets count); cycle completed (counts, DurationMs, SkippedLocked); item failed (PurchaseId, exception) |
| Metrics | promotion_expire_run_total (counter — циклы, liveness); promotion_expire_cycle_duration_seconds (histogram); promotion_expire_items_total{outcome="expired"} (counter); promotion_expire_backlog (gauge — Active покупки с EndsAt < now, ещё не обработанные); promotion_expire_skip_locked_total; promotion_expire_errors_total; outbox lag |
| Traces | correlationId = идентификатор запуска цикла; tenantId (per item); run id — на span цикла и на публикации события |
| Dashboard | Grafana pin-promotion (promotion-overview, панель Background jobs) |
| Alerts | job expire-promotions не отработал > 2 интервалов (нет прироста promotion_expire_run_total); backlog > 100 в течение 5 мин; errors_total rate > 0 устойчиво; outbox backlog pin.promotion.purchase-changed.v1 > 1000 |
Связанный Runbook
| Сценарий | Runbook |
|---|
| Worker завис / backlog растёт | ../runbooks/expire-promotions.md (будущий путь по конвенции) |
| Poison-покупка не завершается | ../runbooks/expire-promotions.md |
Лаг outbox pin.promotion.purchase-changed.v1 | ../runbooks/outbox-backlog.md (будущий путь) |
Тесты
| Тест | Ожидание |
|---|
| Empty batch | Нет Active-покупок с EndsAt <= now → no-op success, событий/мутаций нет |
| Срок истёк | Active, EndsAt <= now → Expired; событие Status=Expired опубликовано (потребитель снимет буст) |
| Partial failure | Poison-покупка исключена и залогирована, остальной батч закоммичен; poison — на следующий цикл |
| Duplicate / параллельные инстансы | FOR UPDATE SKIP LOCKED — каждая покупка обработана один раз, ровно одно событие |
| Гонка с отменой менеджером | Отмена из API до тика → покупка вне выборки либо конфликт по версии → worker не завершает уже терминальную покупку |
| Cancellation | Остановка worker-а во время цикла → батч докоммичен или откачен целиком, блокировки сняты, graceful stop |
| SLO точности | Покупка с EndsAt = t истекает не позже t + Interval + DurationCycle; событие снятия буста уходит в пределах одного интервала |
Обратные ссылки
Автоссылки: где используется этот документ.
Связанные Документы