Background Job: TicketSlaMonitorJob (worker ticket-sla-monitor)
Назначение
Периодический worker ticket-sla-monitor сканирует активные обращения и помечает нарушение SLA: тикеты в
статусах New/InWork, у которых наступил дедлайн первого ответа (FirstResponseDueAt) без публичного
ответа (FirstResponseAt is null) или дедлайн решения (ResolutionDueAt) без перехода в Resolved. По
каждому первом обнаруженном нарушении worker выставляет SlaBreached = true (INV-TKT-6,
ticket.md) и публикует интеграционное событие просрочки, по которому notifications
эскалирует супервизору/дежурным очереди (матрица 4.6). Это единственный писатель SlaBreached и
*DueAt-семантики; штатные FirstResponseAt/ResolvedAt ставят команды support (первый публичный ответ /
Resolve), а гонка «worker ↔ оператор» разводится оптимистической блокировкой.
| Metadata | Значение |
|---|---|
| Owning team | PIN Platform (Care squad) |
| Статус документа | draft |
| Область действия | контекст support, таблица support.tickets, worker ticket-sla-monitor |
| Контекст | greenfield worker (SLA-мониторинг обращений, ADR-0056) |
Принятые решения
| # | Вопрос | Решение |
|---|---|---|
| 1 | Как просрочка попадает в оповещения (кросс-сервис) | Через интеграционное событие просрочки в топик pin.support.ticket-sla-breached.v1 (шаг 4), потребитель notifications — как у CRM timeline-overdue (../../crm/background/timeline-overdue.md). Additive-расширение реестра публикуемых событий (../events/README.md); отдельный контракт-док — по мере стабилизации. |
| 2 | Распределённая блокировка | Нет (ADR-0056: без Redis). Мульти-pod безопасность — построчная блокировка непересекающихся батчей (skip-locked), не distributed lock. |
| 3 | Идемпотентность | Структурная — предикат SlaBreached = false в выборке + guard метода перевода; повтор/рестарт = no-op, отдельный idempotency-ключ не нужен. |
| 4 | Единый SlaBreached bool на два дедлайна | Тикет помечается breached однократно — на первом обнаруженном нарушении (первый ответ наступает раньше решения). Тип нарушения (SlaBreachKind) вычисляется на детекте и несётся событием, но не хранится на агрегате (поле не выдумывается). Сценарий «ответ вовремя, но решение просрочено» фиксируется как Resolution (первый ответ не нарушался → флаг ещё false). Повторной эскалации второго дедлайна нет (упрощение, ADR-0056). |
Классификация
| Поле | Значение |
|---|---|
| Сервис | support |
| Тип job | Recurring (polling-worker) |
| Trigger | цикл 1 мин (UTC) — SLO точности ≤ 1 мин (README контекста) |
| Критичность | tier-2 (SLA поддержки; не денежный путь напрямую) |
Расписание И Locking
| Аспект | Правило |
|---|---|
| Schedule | каждую 1 минуту, UTC (SLO точности ≤ 1 мин — README контекста) |
| Distributed lock | нет (ADR-0056, без Redis); построчная блокировка skip-locked; при желании single-runner — опциональный Postgres advisory-lock Pin:lock:job:scheduled:support-ticket-sla-monitor, не Redis |
| Batch size | BatchSize = 500 строк/батч; до MaxBatchesPerCycle = 20 (потолок 10k строк/цикл) |
| Max duration | целевое < 30 с/цикл (много меньше окна 1 мин) |
| Остановка | проверяется до каждого батча и на каждом I/O; частично-обработанные батчи уже сохранены |
SLO точности цикла
| Показатель | Цель |
|---|---|
| Задержка детекта просрочки | тикет с наступившим FirstResponseDueAt/ResolutionDueAt получает SlaBreached в пределах ≤ 1 мин + джиттер цикла; p99 ≤ 2 мин |
| Backlog (лаг) | count(Status ∈ {New,InWork} and SlaBreached = false and (просрочен любой дедлайн)) в норме → 0 к концу цикла; устойчивый рост = алерт |
| Точность | ложных breach нет (guard-предикат ниже); уже SlaBreached/терминальные тикеты повторно не трогаются |
Вход
| Поле | Тип | Обязательность | Источник | Описание |
|---|---|---|---|---|
NowUtc | DateTimeOffset | Да | системные часы (не из тела) | Отсечка «дедлайн наступил»: *DueAt < NowUtc. |
BatchSize | int | Да | конфигурация worker (default 500) | Размер одного батча выборки под lock. |
MaxBatchesPerCycle | int | Да | конфигурация worker (default 20) | Верхняя граница батчей за цикл. |
TenantId | Guid? | Нет | null = единый tenant платформы (PIN CompanyId) | Опциональное сужение (диагностика/ручной прогон); штатно null — support single-tenant (ADR-0052). |
Актор — system (worker); support — single-tenant platform-tool (в пределах
PIN CompanyId, кросс-tenant scan неприменим). Публичного API/permission у job нет. Payload события PII не несёт (только id/enum/timestamp).
Алгоритм
- Контекст. Цикл worker-а строит вход
{ NowUtc = системные часы, BatchSize, MaxBatchesPerCycle }; актор — system; scope — единый tenant платформы (PIN CompanyId);correlationId= job-run id. Idempotency-ключ не применяется — идемпотентность структурная (шаг 3). - Проверка входа.
BatchSize ∈ [1..1000],MaxBatchesPerCycle ∈ [1..200]— нарушение → ошибка валидации. Проверка сущностей by-id неприменима (батч-скан). Допустимость состояния задана предикатом выборки (шаг 3):Status ∈ {New,InWork},SlaBreached = false. - Что читаем. Выбрать батч тикетов (
top BatchSize) со стабильной сортировкой по (FirstResponseDueAt,Id), удовлетворяющих условию:Status ∉ {Resolved, Closed}иSlaBreached = false, и(FirstResponseAt is null and FirstResponseDueAt < NowUtc)или(Status = InWork and ResolvedAt is null and ResolutionDueAt is not null and ResolutionDueAt < NowUtc). Выборка идёт по partial-индексам(first_response_due_at) where status in ('New','InWork')и(resolution_due_at) where status = 'InWork'(ticket.md → «Индексы»). Строки берутся под построчную skip-locked блокировку — параллельные pod'ы забирают непересекающиеся строки. Пустой батч → шаг 6 (no-op).
- Бизнес-правила и переходы. Для каждой строки вычислить
SlaBreachKind:FirstResponseAt is null and FirstResponseDueAt < NowUtc→FirstResponse(приоритетно, наступает раньше); иначеResolution. Пометить тикет breached (guard INV-TKT-6/INV-TKT-7: толькоNew/InWork,SlaBreached = false; терминальныйClosed/Resolvedне трогается):SlaBreached = true,UpdatedAt = NowUtc. Статус,AssigneeUserId,*DueAt,FirstResponseAt, счётчики — не меняются (просрочка не двигает статус). Guard-предикатSlaBreached = falseделает повторную обработку no-op (идемпотентность). Гонка с командой оператора (проставилFirstResponseAt/Resolveмежду выборкой и записью) разводится оптимистической блокировкой → строка не обновляется / повтор с перечитыванием, ложного breach нет. - События. На каждую переведённую строку — интеграционное
TicketSlaBreachedKafkaEvent(топикpin.support.ticket-sla-breached.v1,ContractVersion = 1,AggregateId = TicketId,AggregateType = "Ticket") со снапшотом (источники — ticket.md):{ TicketId, Number, TenantId, QueueId, RequesterUserId, AssigneeUserId?, Priority (TicketPriority), BreachKind (SlaBreachKind = FirstResponse|Resolution), DueAt, BreachedAt }— без PII (ФИО/телефон/тексты не входят; текст оповещенияnotificationsрендерит поBreachKind/Priority). Потребитель —notifications(эскалация просрочки супервизору/дежурным очереди, 4.6). Пометка breached и факт публикации фиксируются в одном согласованном изменении на границе батча; событие публикуется после фиксации. - Результат.
{ ProcessedCount, HasMore }. Worker повторяет батчи, покаHasMoreи не превышенMaxBatchesPerCycle, затем пишет метрики и завершает цикл. Пустой батч →ProcessedCount = 0,HasMore = false. Инфраструктурная ошибка → батч откатывается, строки разблокируются, следующий цикл (через 1 мин) повторит (идемпотентно).
Граница Транзакции
| Элемент | Правило |
|---|---|
| Единица согласованности | per batch (одно согласованное изменение на батч) |
| Сохраняемое состояние | support.tickets: SlaBreached = true, UpdatedAt; запись события просрочки (по строке) |
| Внешние вызовы | нет синхронных; публикация в Kafka — после фиксации батча |
| Частичная ошибка | весь батч откатывается; skip-locked-строки разблокируются; следующий цикл повторит (guard идемпотентен) |
Постусловие И Межсервисное Окно
| Аспект | Гарантия после цикла |
|---|---|
| Предусловие | Существуют New/InWork-тикеты с наступившим FirstResponseDueAt/ResolutionDueAt и SlaBreached = false; штатные FirstResponseAt/ResolvedAt ставят команды support, а не worker |
| Постусловие (агрегат) | Каждый обнаруженный просроченный тикет имеет SlaBreached = true (однократно, INV-TKT-6) и UpdatedAt = NowUtc; статус/AssigneeUserId/*DueAt/FirstResponseAt/счётчики не изменены; терминальные Resolved/Closed не тронуты |
| Постусловие (события) | На каждый переведённый тикет ушло ровно одно pin.support.ticket-sla-breached.v1 (после фиксации батча) — без дублей при повторах/мульти-pod |
| Окно эскалации (async) | notifications эскалирует супервизору/дежурным очереди по событию просрочки; окно = точность цикла (≤ 1 мин + джиттер) + доставка notifications; downstream — eventual |
| Что видит заявитель | Ничего: просрочка SLA — внутренняя эскалация Службы заботы, Status тикета не меняется, публичного сообщения заявителю нет |
| Второй дедлайн | Тикет, ответивший вовремя, но просрочивший решение, помечается один раз с BreachKind = Resolution; повторной эскалации второго дедлайна нет (упрощение, ADR-0056) |
Идемпотентность И Retry
| Сценарий | Правило |
|---|---|
| Job restart | без checkpoint-состояния — повторная выборка берёт только SlaBreached = false (уже помеченные исключены) |
| Duplicate item / повтор цикла | предикат/guard SlaBreached = false → no-op; повторного события просрочки для той же строки не будет |
| Два pod-а одновременно | построчная skip-locked блокировка даёт непересекающиеся батчи → без двойной обработки и дублей событий |
| Гонка worker ↔ оператор | оператор проставил FirstResponseAt/Resolve → конфликт версии → запись не проходит/повтор с перечиткой; ложного breach нет (INV-TKT-6) |
| Повторяемая ошибка | батч-откат; ретрай на следующем цикле (1 мин); залипания нет (lock снимается с откатом) |
| Poison item | строка вне предиката (Resolved/Closed/уже SlaBreached/дедлайн не наступил) не попадает в выборку → не блокирует прогресс; рост ошибок → алерт |
Наблюдаемость
| Тип | Правило |
|---|---|
| Logs | cycle started {jobRunId}; batch processed {count}; cycle completed {processedTotal, batches, durationMs}; item/batch failed {error} (structured; TicketId/Number/BreachKind) |
| Metrics | support_ticket_sla_breached_total (counter, по BreachKind); support_ticket_sla_backlog (gauge = кол-во просроченных не-breached); support_ticket_sla_cycle_duration_ms; support_ticket_sla_batches_per_cycle; support_ticket_sla_errors_total; support_ticket_sla_lock_skips |
| Traces | correlationId = jobRunId, tenantId = PIN CompanyId, номер батча |
| Dashboard | Grafana pin-support (ops) |
| Alerts | backlog > 100 дольше 10 мин; нет успешного цикла > 5 мин (worker не крутится); cycle_duration > 30 с; рост errors_total (README контекста, «Alerts») |
Связанный Runbook
| Сценарий | Runbook |
|---|---|
| Backlog растёт / детект просрочек отстаёт | ../runbooks/ticket-sla-backlog.md (будущий путь по конвенции) |
| Worker не выполняет циклы | ../runbooks/ticket-sla-not-running.md (будущий путь по конвенции) |
Тесты
| Тест | Ожидание |
|---|---|
| Empty batch | нет просроченных New/InWork без SlaBreached → Processed=0, событий нет |
| Первый ответ просрочен | New, FirstResponseAt=null, FirstResponseDueAt<now → SlaBreached=true, событие BreachKind=FirstResponse |
| Решение просрочено | InWork, FirstResponseAt задан, ResolutionDueAt<now, ResolvedAt=null → SlaBreached=true, BreachKind=Resolution |
Уже SlaBreached / Resolved / Closed | исключён предикатом → не трогается, дублей событий нет |
| Дедлайн не наступил | *DueAt >= now → никогда не breached |
| Гонка с оператором | оператор ответил/решил до записи → конфликт версии → нет ложного breach, single-переход |
| Duplicate run / два pod-а | skip-locked → непересекающиеся строки, ровно один breach + одно событие на строку |
| Cancellation | остановка между батчами; сохранённые батчи сохранены, незавершённый откатан |
Обратные ссылки
Автоссылки: где используется этот документ.
- События (2): События сервиса support: карта топиков и решения об интеграциях, Event Contract: pin.support.ticket-status-changed (TicketStatusChangedKafkaEvent)
Связанные Документы
- Дом мутируемой сущности, INV-TKT-6/7 и SLA-поля (
FirstResponseAt/FirstResponseDueAt/ResolutionDueAt/SlaBreached): ticket.md; соседнее событие статуса — ../events/ticket-status-changed.md; реестр событий — ../events/README.md. - Контекст и Background jobs: ../README.md; эталон worker-а — ../../crm/background/timeline-overdue.md.
- ADR-0056 (polling-worker без Redis, идемпотентность, single-tenant): 0056-simplicity-first-scale-envelope.md; tenancy — ADR-0052.