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

Background Job: TimelineOverdueJob (worker timeline-overdue)

Назначение

Периодический worker timeline-overdue переводит элементы таймлайна сделки, у которых наступил срок, из InProgress в Overdue (иконка «просрочено», US-INV-09) и по каждому переходу публикует карточку в чат сделки / push участникам. Реализует инвариант INV-TLE-4 (deal-timeline-event.md): статус Overdue ставит только этот worker — по элементам со Status = InProgress и наступившим DueAt, идемпотентно. Это единственный писатель статуса Overdue; штатные закрытия (InProgress → Done) делают команды CRM по парным событиям (например DocumentRequested → DocumentUploaded). Дом мутируемой сущности — deal-timeline-event.md; родитель — deal.md.

MetadataЗначение
Owning teamPIN Platform (CRM squad)
Статус документаdraft
Область действияконтекст crm, лента таймлайна сделок, worker timeline-overdue

Принятые решения

#ВопросРешение
1Как карточка «просрочено» попадает в чат/уведомления (кросс-сервис)Через интеграционное событие DealTimelineOverdueEvent в outbox (шаг 6), консюмеры messenger/notifications — как у всех card-to-chat потоков (booking-expired.md). Additive-расширение реестра производимых событий README.
2Распределённая блокировкаНет (ADR-0056: без Redis). Мульти-pod безопасность — построчная блокировка SKIP LOCKED (непересекающиеся батчи), а не distributed lock.
3ИдемпотентностьСтруктурная — предикат Status = InProgress в выборке + доменный guard перехода; повтор/рестарт = no-op, отдельный idempotency-ключ не нужен.

Классификация

ПолеЗначение
Сервисcrm
Тип jobRecurring (polling-worker)
Triggerцикл 5 мин (UTC)
Критичностьtier-2 (важный: корректность вкладки «Ход сделки» и дедлайнов; не денежный путь напрямую)

Расписание и Locking

АспектПравило
Scheduleкаждые 5 минут, UTC (SLO точности — ниже)
Distributed lockнет (ADR-0056, без Redis); построчная блокировка SKIP LOCKED; при желании single-runner — опциональный Postgres advisory Pin:lock:job:scheduled:crm-timeline-overdue, не Redis
Batch sizeBatchSize = 500 строк/батч; до MaxBatchesPerCycle = 20 (потолок 10k строк/цикл)
Max durationцелевое < 60 с/цикл (много меньше окна 5 мин)
Cancellationостановка проверяется до каждого батча и на каждом I/O; частично-обработанные батчи уже зафиксированы

SLO точности цикла

ПоказательЦель
Задержка детекта просрочкиэлемент InProgress с DueAt получает Overdue в пределах ≤ 5 мин + джиттер цикла от DueAt; p99 ≤ 6 мин
Backlog (лаг)count(Status = InProgress and DueAt < now) в норме → 0 к концу цикла; устойчивый рост = алерт
Точностьложных Overdue нет (guard Status = InProgress and DueAt < now); Done/Overdue повторно не трогаются

Входные параметры цикла

ПолеТипОбязательностьИсточникОписание
NowUtcDateTimeOffsetДасистемные часы (не из тела)Отсечка «срок наступил»: DueAt < NowUtc.
BatchSizeintДаконфигурация worker (default 500)Размер одного батча выборки под блокировкой.
MaxBatchesPerCycleintДаконфигурация worker (default 20)Верхняя граница батчей за цикл (защита от долгого цикла).
TenantIdGuid?Нетnull = все tenant'ыОпциональное сужение (диагностика/ручной прогон); штатно null.

Актор — system (worker); cross-tenant scan разрешён явно (worker обслуживает все tenant'ы, README → Background jobs). Публичного API/permission у job нет. Payload события PII не несёт (только id/enum/timestamp), маскирование не требуется.

Алгоритм

  1. Контекст. Цикл worker-а стартует с отсечкой NowUtc (системные часы), BatchSize, MaxBatchesPerCycle; actor = system, cross-tenant разрешён явно; correlationId = job-run id. Идемпотентность структурная (шаг 5), не по ключу.
  2. Проверка входа. BatchSize ∈ [1..1000], MaxBatchesPerCycle ∈ [1..200] → иначе ValidationError. Существование сущностей by-id не применяется (батч-скан). Допустимость состояния — в предикате шага 3 (Status = InProgress).
  3. Что читаем. Батч элементов таймлайна с Status = InProgress, заданным DueAt и DueAt < NowUtc, порядок по DueAt asc, Id asc (стабильный ключ; выборка по partial-индексу «status = InProgress», deal-timeline-event.md → Индексы). Строки берутся под построчной блокировкой SKIP LOCKED — параллельные pod'ы забирают непересекающиеся строки. Пустой батч → шаг 8 (no-op).
  4. Бизнес-правила и переходы. Для каждой строки — доменный переход InProgress → Overdue (INV-TLE-4): Status = Overdue (источник — переход), UpdatedAt = NowUtc; CompletedAt не трогается (остаётся null). Контент события (Type, OccurredAt, Payload, DueAt, ActorUserId) иммутабелен (INV-TLE-1, append-only). ActorRole/ActorUserId события просрочки — null (система).
  5. Что меняется. Обновляются Status/UpdatedAt строк батча. Guard-предикат Status = InProgress (в выборке шага 3 и в условии обновления) делает повторную обработку no-op (идемпотентность). Строки уже под построчной блокировкой в этой записи — гонок значений нет.
  6. Событие. На каждую переведённую строку — интеграционное DealTimelineOverdueEvent (topic pin.crm.timeline-overdue.v1, ContractVersion = 1, AggregateType = "Deal") через транзакционный outbox. Payload (источники — deal-timeline-event.md): { DealId: Guid, TimelineEventId: Guid, TenantId: Guid, Type: DealTimelineEventType (string enum), DueAt: DateTimeOffset, OverdueAt: DateTimeOffset } — без PII (ФИО/суммы/эскроу не входят; текст карточки messenger рендерит по Type). Консюмеры: messenger (карточка «шаг просрочен» в чат сделки, US-INV-16/11), notifications (push/email участникам, US-INV-11).
  7. Согласованность. Мутации строк батча + запись outbox фиксируются атомарно в одной записи. Граница — один батч; «checkpoint» — продвижение по DueAt, Id; следующий батч — новая запись и новый набор заблокированных строк.
  8. Результат. { ProcessedCount, HasMore }. Worker повторяет батчи, пока HasMore и не превышен MaxBatchesPerCycle, затем пишет метрики и завершает цикл. Пустой батч → ProcessedCount = 0, HasMore = false. Инфраструктурная ошибка → батч откатывается, строки разблокируются, следующий цикл (через 5 мин) повторит (идемпотентно).

Граница транзакции

ЭлементПравило
Единица согласованностиper batch (одна атомарная запись на батч)
Сохраняемое состояниелента таймлайна: Status = Overdue, UpdatedAt; запись outbox (по строке)
External callsнет синхронных; публикация в Kafka — outbox-publisher после commit
Частичная ошибкавесь батч откатывается; блокировки строк снимаются; следующий цикл повторит (guard идемпотентен)

Постусловия и цепочка следствий

Предусловия цикла. Существуют элементы таймлайна со Status = InProgress, заданным DueAt и наступившим сроком (DueAt < NowUtc); консюмеры messenger и notifications подписаны на pin.crm.timeline-overdue.v1.

Постусловия (после commit батча). Каждый обработанный элемент — Status = Overdue, UpdatedAt = NowUtc; контент события (Type, OccurredAt, Payload, DueAt, ActorUserId) иммутабелен (INV-TLE-1), CompletedAt остаётся null; по каждому переходу — запись DealTimelineOverdueEvent в outbox.

Цепочка следствий (асинхронно, eventual consistency). DealTimelineOverdueEvent после commit публикуется outbox-publisher-ом и разветвляется на двух консюмеров:

  1. messenger — карточка «шаг просрочен» в чат сделки (US-INV-16/11); текст рендерится по Type (payload PII не несёт).
  2. notifications — push/email участникам сделки (US-INV-11).
Что видит пользователь в промежутке

Во вкладке «Ход сделки» элемент получает иконку «просрочено» сразу после commit; карточка в чате сделки и push/email участникам появляются позже — после обработки события консюмерами messenger/notifications (окно лага консюмера).

Идемпотентность и retry

СценарийПравило
Job restartбез checkpoint-состояния — повторная выборка снова возьмёт только Status = InProgress (уже переведённые исключены)
Duplicate item / повтор циклапредикат/guard Status = InProgress → no-op; повторного DealTimelineOverdueEvent для той же строки не будет
Два pod-а одновременнопострочная блокировка SKIP LOCKED даёт непересекающиеся батчи → без двойной обработки и дублей событий
Повторяемая ошибкабатч-откат; ретрай на следующем цикле (5 мин); залипания нет (блокировка снимается с откатом)
Poison itemстрока с DueAt = null или Status ≠ InProgress не попадает в выборку → не блокирует прогресс; рост ошибок → алерт (ниже)

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

ТипПравило
Logscycle started {jobRunId}; batch processed {count}; cycle completed {processedTotal, batches, durationMs}; item/batch failed {error} (structured)
Metricscrm_timeline_overdue_processed_total (counter); crm_timeline_overdue_backlog (gauge = count(Status=InProgress and DueAt<now)); crm_timeline_overdue_cycle_duration_ms; crm_timeline_overdue_batches_per_cycle; crm_timeline_overdue_errors_total; crm_timeline_overdue_lock_skips
TracescorrelationId = jobRunId, tenantId (per обработанной строке; на цикл — cross-tenant), номер батча
DashboardGrafana pin-crm
Alertsbacklog > 500 дольше 15 мин (лаг детекта); нет успешного цикла > 10 мин (worker не крутится); cycle_duration > 60 с; рост errors_total

Связанный Runbook

СценарийRunbook
Backlog растёт / детект просрочек отстаёт../runbooks/timeline-overdue-backlog.md (будущий путь по конвенции)
Worker не выполняет циклы../runbooks/timeline-overdue-not-running.md (будущий путь по конвенции)

Тесты

ТестОжидание
Empty batchнет строк InProgress/DueAt<nowProcessed=0, событий нет
Happy pathстрока InProgress, DueAt<nowOverdue, UpdatedAt=NowUtc, одно DealTimelineOverdueEvent в outbox
Уже Overdue/Doneисключена предикатом → не трогается, дублей событий нет (идемпотентность)
DueAt = null или DueAt >= nowникогда не Overdue
Partial failureбатч откатывается; строки не заблокированы; следующий цикл повторяет; итог — single Overdue+событие
Duplicate run / два pod-аSKIP LOCKED → непересекающиеся строки, ровно один переход и одно событие на строку
Cancellationостановка между батчами; зафиксированные батчи сохранены, незавершённый откатан

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

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

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