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

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 teamPIN 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
Тип jobRecurring (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 sizeBatchSize = 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/терминальные тикеты повторно не трогаются

Вход

ПолеТипОбязательностьИсточникОписание
NowUtcDateTimeOffsetДасистемные часы (не из тела)Отсечка «дедлайн наступил»: *DueAt < NowUtc.
BatchSizeintДаконфигурация worker (default 500)Размер одного батча выборки под lock.
MaxBatchesPerCycleintДаконфигурация worker (default 20)Верхняя граница батчей за цикл.
TenantIdGuid?Нет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).

Алгоритм

  1. Контекст. Цикл worker-а строит вход { NowUtc = системные часы, BatchSize, MaxBatchesPerCycle }; актор — system; scope — единый tenant платформы (PIN CompanyId); correlationId = job-run id. Idempotency-ключ не применяется — идемпотентность структурная (шаг 3).
  2. Проверка входа. BatchSize ∈ [1..1000], MaxBatchesPerCycle ∈ [1..200] — нарушение → ошибка валидации. Проверка сущностей by-id неприменима (батч-скан). Допустимость состояния задана предикатом выборки (шаг 3): Status ∈ {New,InWork}, SlaBreached = false.
  3. Что читаем. Выбрать батч тикетов (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).
  4. Бизнес-правила и переходы. Для каждой строки вычислить SlaBreachKind: FirstResponseAt is null and FirstResponseDueAt < NowUtcFirstResponse (приоритетно, наступает раньше); иначе 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 нет.
  5. События. На каждую переведённую строку — интеграционное 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 и факт публикации фиксируются в одном согласованном изменении на границе батча; событие публикуется после фиксации.
  6. Результат. { 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/дедлайн не наступил) не попадает в выборку → не блокирует прогресс; рост ошибок → алерт

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

ТипПравило
Logscycle started {jobRunId}; batch processed {count}; cycle completed {processedTotal, batches, durationMs}; item/batch failed {error} (structured; TicketId/Number/BreachKind)
Metricssupport_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
TracescorrelationId = jobRunId, tenantId = PIN CompanyId, номер батча
DashboardGrafana pin-support (ops)
Alertsbacklog > 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 без SlaBreachedProcessed=0, событий нет
Первый ответ просроченNew, FirstResponseAt=null, FirstResponseDueAt<nowSlaBreached=true, событие BreachKind=FirstResponse
Решение просроченоInWork, FirstResponseAt задан, ResolutionDueAt<now, ResolvedAt=nullSlaBreached=true, BreachKind=Resolution
Уже SlaBreached / Resolved / Closedисключён предикатом → не трогается, дублей событий нет
Дедлайн не наступил*DueAt >= now → никогда не breached
Гонка с операторомоператор ответил/решил до записи → конфликт версии → нет ложного breach, single-переход
Duplicate run / два pod-аskip-locked → непересекающиеся строки, ровно один breach + одно событие на строку
Cancellationостановка между батчами; сохранённые батчи сохранены, незавершённый откатан

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

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

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