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; активные сделки лида не трогаются.
ExpiresAt, не TTL-магияМеханизм expiry — polling по ExpiresAt (ADR-0056): срок жизни лида продлевается агентом/активностью
клиента, а перевод должен порождать интеграционное событие.
| Metadata | Значение |
|---|---|
| Owning team | PIN Platform (CRM squad) |
| Статус документа | draft |
| Область действия | контекст crm, лиды |
| Зависимости | PostgreSQL (crm), Kafka (outbox → pin.crm.lead-expired.v1) |
| Assumptions | цель 1–10k активных (ADR-0056); просроченных лидов за цикл — сотни, не миллионы |
Классификация
| Поле | Значение |
|---|---|
| Сервис | crm |
| Тип job | Recurring (polling) |
| Trigger | interval 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 size | 500 (конфиг Crm:Jobs:LeadExpiration:BatchSize), цикл дренирует до пустого batch |
| Max duration | MaxBatchesPerCycle = 200 (safety-cap ≈ 100k лидов/цикл); превышение → лог + добор в следующем цикле |
| Cancellation | остановка хоста проверяется перед каждым batch; текущий batch дорабатывается или откатывается целиком |
Входные параметры цикла
| Поле | Тип | Обязательность | Источник | Описание |
|---|---|---|---|---|
TenantId | — | Нет | — | Не применяется — платформенный maintenance-job, cross-tenant scan разрешён явно (ADR-0052 не нарушается: пишет только Status/ExpiredAt). |
Now | DateTimeOffset | Да | снимок системных часов на старте цикла | Единая «точка отсечки» для всех batch-ей цикла — консистентность выборки. |
BatchSize | int | Да | конфиг (default 500) | Размер страницы выборки. |
MaxBatchesPerCycle | int | Да | конфиг (default 200) | Верхний предел batch-ей за один прогон. |
Алгоритм
- Контекст. Worker по тику цикла берёт отсечку
Now(системные часы),BatchSize,MaxBatchesPerCycle. Actor = system; cross-tenant разрешён явно (без tenant-фильтра).jobRunId/correlationIdгенерируются на цикл. Отдельный idempotency-ключ не нужен — идемпотентность структурная (шаги 3–4). - Проверка входа.
BatchSize > 0,MaxBatchesPerCycle > 0→ иначеValidationError. Per-item проверка состояния выполняется в шаге 4 (доменный guard), а не отдельным чтением: предикат выборки уже гарантируетStatus = Active. - Что читаем. Цикл до
MaxBatchesPerCycleбатчей: одна страница лидов соStatus = ActiveиExpiresAt < Now, порядок поExpiresAt asc(индексix_leads_expires_at), под построчной блокировкойSKIP LOCKED. Обработанные строки на следующей итерации ужеExpiredи выпадают из предиката. Пустой batch → выход из цикла. - Бизнес-правила и переходы. Для каждого лида в batch — доменный переход
Expire(Now): guardStatus == 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. - Что меняется. Обновляются
Status/ExpiredAt/UpdatedAt/версия лидов batch-а (уже заблокированных). Конфликт версий исключён самой построчной блокировкой. - Событие. Интеграционное
LeadExpiredEvent(ContractVersion = 1, topicpin.crm.lead-expired.v1,AggregateType = "Lead") кладётся в транзакционный outbox — по одному на просроченный лид. Контракт: ../events/lead-expired.md. Внутрисервисной реакции на expiry нет. - Согласованность. Граница — per batch: все переходы batch-а + записи outbox фиксируются атомарно в
одной записи; блокировки строк освобождаются на commit. Следующий batch — новая запись. Ошибка внутри
batch → полный rollback batch-а (лиды остаются
Active), цикл продолжается со следующего batch-а. - Результат.
{ ExpiredCount, BatchesProcessed, Elapsed, CappedByMaxBatches: bool }. Метрики публикуются (раздел Наблюдаемость); отказ возможен только на инфраструктурном сбое всего цикла.
Граница транзакции
| Элемент | Правило |
|---|---|
| Единица согласованности | per batch (не per item, не per cycle) |
| Сохраняемое состояние | лиды (Status, ExpiredAt, UpdatedAt, Version) + записи outbox (по лиду) |
| External calls | Kafka-публикация — только после 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 нет — очереди на входе нет). |
Наблюдаемость
| Тип | Правило |
|---|---|
| Logs | job.started {jobRunId, Now}; batch.processed {jobRunId, size, elapsedMs}; lead.expired {LeadId, TenantId} (Debug); item.failed {LeadId, error}; job.completed {jobRunId, ExpiredCount, BatchesProcessed, cappedByMaxBatches} |
| Metrics | crm_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 |
| Traces | correlationId/jobRunId, tenantId (per item); span на цикл и на batch |
| SLO | Лид переходит в Expired не позднее 1 цикла после ExpiresAt: p95 задержки ExpiredAt − ExpiresAt < 1 ч 5 мин (цикл 1 ч + дренаж). При backlog > BatchSize × MaxBatchesPerCycle за цикл SLO под угрозой. |
| Alerts | crm_lead_expiration_backlog не убывает 2 цикла подряд (worker завис/не успевает); errors_total > 0 за цикл; отсутствие job.completed > 2 ч (dead-man); наследует общий алерт «outbox backlog > 1000» из ../README.md. |
| Dashboard | Grafana 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 < Now → Status = Expired, ExpiredAt проставлен, ровно одна запись outbox (pin.crm.lead-expired.v1). |
ExpiresAt в будущем / уже Expired/Archived | Не выбирается предикатом — не трогается. |
| Duplicate run (два pod-а) | Один и тот же лид переводится ровно один раз; SKIP LOCKED разводит batch-и; событий — один. |
| Partial failure в batch | Rollback batch-а: лиды остаются Active, outbox пуст; следующий цикл переводит их. |
| Активная сделка у лида | При переводе лида в Expired сделка/фиксация не изменяются (INV-LEAD-4). |
| Cancellation | Между batch-ами — graceful stop без частичного commit; внутри batch — атомарный rollback. |
MaxBatchesPerCycle достигнут | CappedByMaxBatches = true, остаток — следующий цикл; без ошибки. |
Связанные документы
- Дом агрегата: ../domain/lead.md (INV-LEAD-4,
Status,ExpiresAt,ExpiredAt); контракт события: ../events/lead-expired.md; карта событий: ../README.md. - Эталон polling-worker-а: ../../user-activity/domain/property-object-view-stats.md.
- ADR: 0056 simplicity-first (polling, построчная блокировка, без Redis), 0052 tenancy.
- SDK: 05 — Repository Pipeline, 11 — Distributed Locking.