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

Background Job: LeadExpirationJob

Назначение

LeadExpirationJob — периодический polling-worker контекста crm (worker lead-expiration), который переводит просроченные лиды из Active в Expired: выбирает лиды с истёкшим ExpiresAt, меняет Status и публикует LeadExpiredEvent (агенту — «продлите лид»). Реализует инвариант INV-LEAD-4 дома ../domain/lead.md: перевод в Expired — только worker-ом при ExpiresAt < now и Status = Active; активные сделки лида не трогаются.

примечание
Polling по ExpiresAt, не TTL-магия

Механизм expiry — polling по ExpiresAt (ADR-0056): срок жизни лида продлевается агентом/активностью клиента, а перевод должен порождать интеграционное событие.

MetadataЗначение
Owning teamPIN Platform (CRM squad)
Статус документаdraft
Область действияконтекст crm, лиды
ЗависимостиPostgreSQL (crm), Kafka (outbox → pin.crm.lead-expired.v1)
Assumptionsцель 1–10k активных (ADR-0056); просроченных лидов за цикл — сотни, не миллионы

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

ПолеЗначение
Сервисcrm
Тип jobRecurring (polling)
Triggerinterval 1 h (конфиг Crm:Jobs:LeadExpiration:Period = 01:00:00)
КритичностьTier-2 (задержка → устаревший Active-лид и отложенное уведомление; денег не теряет)

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

АспектПравило
Scheduleцикл 1 h, UTC; к минуте не привязан — точность цикла не критична (лид живёт ~месяц)
Distributed lockне используется (ADR-0056 — без Redis). Конкурентность между pod-ами — на уровне строк: построчная блокировка SKIP LOCKED выдаёт каждому pod-у непересекающийся batch
Batch size500 (конфиг Crm:Jobs:LeadExpiration:BatchSize), цикл дренирует до пустого batch
Max durationMaxBatchesPerCycle = 200 (safety-cap ≈ 100k лидов/цикл); превышение → лог + добор в следующем цикле
Cancellationостановка хоста проверяется перед каждым batch; текущий batch дорабатывается или откатывается целиком

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

ПолеТипОбязательностьИсточникОписание
TenantIdНетНе применяется — платформенный maintenance-job, cross-tenant scan разрешён явно (ADR-0052 не нарушается: пишет только Status/ExpiredAt).
NowDateTimeOffsetДаснимок системных часов на старте циклаЕдиная «точка отсечки» для всех batch-ей цикла — консистентность выборки.
BatchSizeintДаконфиг (default 500)Размер страницы выборки.
MaxBatchesPerCycleintДаконфиг (default 200)Верхний предел batch-ей за один прогон.

Алгоритм

  1. Контекст. Worker по тику цикла берёт отсечку Now (системные часы), BatchSize, MaxBatchesPerCycle. Actor = system; cross-tenant разрешён явно (без tenant-фильтра). jobRunId/correlationId генерируются на цикл. Отдельный idempotency-ключ не нужен — идемпотентность структурная (шаги 3–4).
  2. Проверка входа. BatchSize > 0, MaxBatchesPerCycle > 0 → иначе ValidationError. Per-item проверка состояния выполняется в шаге 4 (доменный guard), а не отдельным чтением: предикат выборки уже гарантирует Status = Active.
  3. Что читаем. Цикл до MaxBatchesPerCycle батчей: одна страница лидов со Status = Active и ExpiresAt < Now, порядок по ExpiresAt asc (индекс ix_leads_expires_at), под построчной блокировкой SKIP LOCKED. Обработанные строки на следующей итерации уже Expired и выпадают из предиката. Пустой batch → выход из цикла.
  4. Бизнес-правила и переходы. Для каждого лида в batch — доменный переход Expire(Now): guard Status == Active (INV-LEAD-4; при гонке — no-op), Active → Expired, ExpiredAt = Now, UpdatedAt = Now. Активные сделки/фиксации лида не трогаются. Событие: LeadExpiredEvent.LeadId = lead.Id, .TenantId = lead.TenantId, .AgentUserId = lead.ExecutorAgentUserId (текущий исполнитель — адресат «продлите лид»), .InvestorUserId = lead.PrimaryInvestorUserId, .ExpiredAt = lead.ExpiredAt.
  5. Что меняется. Обновляются Status/ExpiredAt/UpdatedAt/версия лидов batch-а (уже заблокированных). Конфликт версий исключён самой построчной блокировкой.
  6. Событие. Интеграционное LeadExpiredEvent (ContractVersion = 1, topic pin.crm.lead-expired.v1, AggregateType = "Lead") кладётся в транзакционный outbox — по одному на просроченный лид. Контракт: ../events/lead-expired.md. Внутрисервисной реакции на expiry нет.
  7. Согласованность. Граница — per batch: все переходы batch-а + записи outbox фиксируются атомарно в одной записи; блокировки строк освобождаются на commit. Следующий batch — новая запись. Ошибка внутри batch → полный rollback batch-а (лиды остаются Active), цикл продолжается со следующего batch-а.
  8. Результат. { ExpiredCount, BatchesProcessed, Elapsed, CappedByMaxBatches: bool }. Метрики публикуются (раздел Наблюдаемость); отказ возможен только на инфраструктурном сбое всего цикла.

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

ЭлементПравило
Единица согласованностиper batch (не per item, не per cycle)
Сохраняемое состояниелиды (Status, ExpiredAt, UpdatedAt, Version) + записи outbox (по лиду)
External callsKafka-публикация — только после commit, через outbox-publisher (не в записи job-а)
Частичная ошибкаrollback текущего batch-а → лиды остаются Active; цикл продолжается; недоставленное доберётся следующим циклом

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

Предусловия цикла. Существуют активные лиды с наступившим ExpiresAt; продление агентом или активностью клиента ранее сдвигало ExpiresAt вперёд; консюмер notifications подписан на pin.crm.lead-expired.v1.

Постусловия (после commit батча). Каждый обработанный лид — Status = Expired, ExpiredAt = Now, версия +1; по каждому — запись LeadExpiredEvent в outbox. Активные сделки/фиксации лида не изменены (INV-LEAD-4).

Цепочка следствий (асинхронно, eventual consistency). LeadExpiredEvent после commit публикуется outbox-publisher-ом → консюмер notifications уведомляет агента («продлите лид»). Внутрисервисной реакции на expiry нет.

Что видит пользователь в промежутке. Агент видит лид как Expired в списке лидов сразу после commit; уведомление «продлите лид» приходит после обработки lead-expired консюмером notifications (окно лага консюмера). Просроченный лид остаётся в системе (не удаляется) — агент может продлить его повторным действием.

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

СценарийПравило
Job restart / повторный прогонCheckpoint не нужен: предикат Status = Active AND ExpiresAt < Now сам исключает уже переведённые (Expired) лиды — повтор no-op.
Конкурентные pod-ы / duplicate itemПострочная блокировка SKIP LOCKED отдаёт строку только одному pod-у; второй её пропускает. Дополнительно guard Status == Active в переходе.
Повторяемая ошибкаBatch откатывается; следующий цикл (через 1 ч) повторяет. Внутрицикловый retry не нужен — окно велико относительно SLO.
Poison item (лид всегда падает)Batch с poison-строкой изолируется повтором по одному (BatchSize = 1) с логом LeadId; строка, что валит и single-item обработку, пропускается и алертится (DLQ нет — очереди на входе нет).

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

ТипПравило
Logsjob.started {jobRunId, Now}; batch.processed {jobRunId, size, elapsedMs}; lead.expired {LeadId, TenantId} (Debug); item.failed {LeadId, error}; job.completed {jobRunId, ExpiredCount, BatchesProcessed, cappedByMaxBatches}
Metricscrm_lead_expiration_expired_total (counter); crm_lead_expiration_duration_seconds (histogram); crm_lead_expiration_backlog (gauge — count(Status=Active AND ExpiresAt<now) перед циклом); crm_lead_expiration_errors_total
TracescorrelationId/jobRunId, tenantId (per item); span на цикл и на batch
SLOЛид переходит в Expired не позднее 1 цикла после ExpiresAt: p95 задержки ExpiredAt − ExpiresAt < 1 ч 5 мин (цикл 1 ч + дренаж). При backlog > BatchSize × MaxBatchesPerCycle за цикл SLO под угрозой.
Alertscrm_lead_expiration_backlog не убывает 2 цикла подряд (worker завис/не успевает); errors_total > 0 за цикл; отсутствие job.completed > 2 ч (dead-man); наследует общий алерт «outbox backlog > 1000» из ../README.md.
DashboardGrafana pin-crm (панель Background jobs).

Связанный Runbook

СценарийRunbook
Backlog растёт / worker не успевает../runbooks/lead-expiration-backlog.md (будущий путь по конвенции)
Poison-лид блокирует batch../runbooks/lead-expiration-poison-item.md (будущий путь)
Outbox не публикует lead-expiredобщий ../runbooks/outbox-stuck.md (будущий путь)

Тесты

ТестОжидание
Empty batch (нет просроченных)Цикл — no-op success, ExpiredCount = 0.
Happy pathЛид Active с ExpiresAt < NowStatus = Expired, ExpiredAt проставлен, ровно одна запись outbox (pin.crm.lead-expired.v1).
ExpiresAt в будущем / уже Expired/ArchivedНе выбирается предикатом — не трогается.
Duplicate run (два pod-а)Один и тот же лид переводится ровно один раз; SKIP LOCKED разводит batch-и; событий — один.
Partial failure в batchRollback batch-а: лиды остаются Active, outbox пуст; следующий цикл переводит их.
Активная сделка у лидаПри переводе лида в Expired сделка/фиксация не изменяются (INV-LEAD-4).
CancellationМежду batch-ами — graceful stop без частичного commit; внутри batch — атомарный rollback.
MaxBatchesPerCycle достигнутCappedByMaxBatches = true, остаток — следующий цикл; без ошибки.

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