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

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), переход и публикация события — атомарно.

ПолеЗначение
OwnerPIN 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
Тип jobRecurring (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 size200 покупок/цикл (конфигурируется); backlog добирается следующими циклами (order by EndsAt asc)
Max durationцель < 30 с/цикл; при превышении — тик пропускается, добор в следующем цикле
Cancellationпри остановке worker-а текущий batch докоммичивается или откатывается целиком, блокировки строк освобождаются

Входная Модель ExpirePromotionsInput

ПолеТипОбязательностьИсточникОписание
AsOfDateTimeOffsetДасистемные часы (UTC)Момент цикла для сравнения с EndsAt.
BatchSizeintДаконфигурация (default 200)Верхняя граница покупок за цикл.
TenantIdGuid?Нетне применяетсяCross-tenant scan: платформенный System-worker без tenant-фильтра (ADR-0052 п.3), обрабатывает покупки всех компаний.

Алгоритм

Каждый тик расписания запускает один цикл истечения (актор — System):

  1. Контекст цикла. Актор — System; cross-tenant разрешён явно (обрабатываются покупки всех компаний); correlationId = идентификатор запуска цикла; AsOf = текущее системное время (UTC). Внешнего ввода нет; бизнес-идемпотентность обеспечивается предикатом статуса (шаг 2).
  2. Отбор кандидатов. Отобрать покупки в статусе Status = Active c EndsAt <= AsOf (INV-6 — терминальные Expired/Cancelled не выбираются), сортировка EndsAt asc (сначала самые старые), не более BatchSize за цикл; цели покупок нужны для снапшота события. Выбранные строки блокируются (FOR UPDATE SKIP LOCKED) до конца цикла и пропускаются, если залочены другим инстансом или in-flight отменой POST /promotions/{id}/cancel.
  3. Переход. Для каждой покупки — Active → Expired (guard INV-6): Status ← Expired, UpdatedAt ← AsOf, Version += 1. Пересчёта буста в промо-контексте нет (денормализация — у потребителей, ADR-0060).
  4. Сохранение и событие. Переходы батча и события PromotionPurchaseChangedKafkaEvent (топик pin.promotion.purchase-changed.v1, key = PurchaseId, Status = Expired) фиксируются в одном согласованном изменении на батч; блокировки строк держатся до фиксации. Дом контракта события — promotion-purchase-changed.md (PII не несёт). Poison-покупка (неожиданное состояние) исключается из батча, логируется и остаётся на следующий цикл; остальной батч фиксируется (continue, не stop).
  5. Результат. Счётчики 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 callsKafka-публикация — после фиксации (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

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

ТипПравило
Logscycle started (runId, AsOf); batch selected (count); promotion expired (PurchaseId, TenantId, PackageKind, targets count); cycle completed (counts, DurationMs, SkippedLocked); item failed (PurchaseId, exception)
Metricspromotion_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
TracescorrelationId = идентификатор запуска цикла; tenantId (per item); run id — на span цикла и на публикации события
DashboardGrafana pin-promotion (promotion-overview, панель Background jobs)
Alertsjob 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 <= nowExpired; событие Status=Expired опубликовано (потребитель снимет буст)
Partial failurePoison-покупка исключена и залогирована, остальной батч закоммичен; poison — на следующий цикл
Duplicate / параллельные инстансыFOR UPDATE SKIP LOCKED — каждая покупка обработана один раз, ровно одно событие
Гонка с отменой менеджеромОтмена из API до тика → покупка вне выборки либо конфликт по версии → worker не завершает уже терминальную покупку
CancellationОстановка worker-а во время цикла → батч докоммичен или откачен целиком, блокировки сняты, graceful stop
SLO точностиПокупка с EndsAt = t истекает не позже t + Interval + DurationCycle; событие снятия буста уходит в пределах одного интервала

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

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

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