Background Job: CallTimeoutReaperJob
Назначение
CallTimeoutReaperJob — периодический фоновый worker сервиса messenger, закрывающий «зависшие» звонки
CallSessionEntity, которые остались в активных статусах, потому что штатный
сигнал завершения не пришёл (адресат не ответил; обе стороны отвалились без hangup; сеть/сигналинг
оборвались). Обрабатывает две фазы над одним агрегатом:
- ring-timeout (INV-CALL-2) —
Status = Ringing при now − StartedAt > RingTimeout (default 45 с) →
Missed, EndReason = Timeout; push CallMissed инициатору;
- stale-answered —
Status = Answered, чей UpdatedAt <= now − StaleAnsweredTimeout (нет прогресса
сигналинга/heartbeat; обе стороны пропали) → Failed, EndReason = Error (Answered → Failed); push обеим
сторонам.
Сигнал живости для фазы stale-answered — только доменный UpdatedAt звонка: messenger вне медиа-плоскости и
медиа-heartbeat не хранит (INV-CALL-4), поэтому «прогрессом» считается любая фиксация по звонку (штатный
end-call, повторные сигнальные операции). Штатное завершение всегда должен слать клиент; эта фаза — грубый
backstop с большим окном (StaleAnsweredTimeout, default 2 ч) на случай, когда обе стороны исчезли, не
отправив end, — она закрывает утечку «вечно активного» звонка, а не измеряет реальную длительность разговора.
Оба перехода терминальны (INV-CALL-3), поднимают доменное событие (CallMissedEvent / CallEndedEvent) и
в той же транзакции пишут строку-намерение realtime-push в messenger.realtime_outbox
(Kind = CallMissed для Ringing → Missed, Kind = CallEnded для Answered → Failed) — persist-then-push
(ADR-0055): фактическую доставку в хаб выполняет воркер realtime-dispatch. Так закрывается
разрыв, при котором reaper пушил CallMissed без соответствующей outbox-строки: RealtimeDispatchKind теперь
включает CallMissed. SDP/ICE/токены не сохраняются (INV-CALL-4 — в хранилище только факты). Интеграционных
Kafka-событий звонок не публикует (call-session.md). Цикл — polling без Redis/TTL
(ADR-0056 п.3): выборка активных звонков с single-runner блокировкой, переход фиксируется атомарно,
идемпотентно (по guard-условию статуса Ringing/Answered).
| Metadata | Значение |
|---|
| Owning team | PIN Platform (Comms squad) |
| Статус документа | draft |
| Область действия | worker call-timeout-reaper, таблицы messenger.call_sessions + messenger.realtime_outbox (строка push в той же транзакции); доставка в хаб /hubs/messenger — воркером realtime-dispatch; Kafka не затрагивается |
| Assumptions / ограничения | основная фаза — Ringing → Missed; целевой объём звонков мал (ADR-0056); точность закрытия — RingTimeout + Interval; заявлен в README «Background jobs» |
| Зависимости | PostgreSQL (messenger.call_sessions, messenger.realtime_outbox); realtime-доставка — отдельный воркер realtime-dispatch |
Классификация
| Поле | Значение |
|---|
| Сервис | messenger |
| Тип job | периодический (PeriodicTimer) |
| Trigger | таймер каждые PollingInterval (default 15 с — низкий, ради точности окна Missed) |
| Критичность | tier-2 (журнал звонков и UX; сбой → «висящий» звонок в UI, не потеря данных) |
Расписание и locking
| Аспект | Правило |
|---|
| Schedule | фиксированный PollingInterval (default 15 с, UTC); тик не стартует поверх идущего цикла |
| Distributed lock | single-runner: попытка захватить блокировку прогона без ожидания, TTL LockTtl (default 1 мин); backend — PostgreSQL advisory. Блокировка занята → прогон пропускается |
| Row-level | claim-выборка с пропуском уже заблокированных строк (skip-locked, defense-in-depth); переход всё равно идемпотентен по guard-условию статуса |
| Batch size | BatchSize звонков/цикл на фазу (default 200); backlog добирается циклами (порядок по StartedAt asc) |
| Max duration | MaxRunDuration (default 30 с; цель < 5 с/цикл) |
| Cancellation | остановка хоста учитывается на каждом обращении к хранилищу; текущий batch докоммичивается или откатывается целиком, блокировки снимаются |
Внешнего runtime-входа нет; параметры — CallTimeoutReaperOptions (валидация на старте).
| Поле | Тип | Обязательность | Источник | Описание |
|---|
AsOf | DateTimeOffset | Да | часы системы | Момент цикла для сравнения с StartedAt/UpdatedAt. |
RingTimeout | TimeSpan | Да | CallTimeoutReaperOptions (default 45 с) | Окно ответа: Ringing дольше → Missed (INV-CALL-2). |
StaleAnsweredTimeout | TimeSpan | Да | CallTimeoutReaperOptions (default 2 ч) | Answered без прогресса дольше → Failed. |
BatchSize | int | Да | CallTimeoutReaperOptions (default 200) | Верхняя граница звонков за фазу за цикл. |
TenantId | TenantId | Нет | не применяется | Cross-tenant scan: платформенный System-worker без tenant-фильтра (звонки — вне tenant, ADR-0052 п.3). |
Алгоритм
Каждый тик обрабатывает две фазы по порядку — сначала RingTimeout, затем StaleAnswered (переход в терминал
делает звонок невыбираемым во второй фазе).
- Контекст и доступ. Источник запуска — расписание; актор — System (scope-фильтры отключены, cross-tenant
разрешён явно);
correlationId = jobRunId; AsOf — текущее время. Идемпотентность — не по конверту
события, а по guard-условию статуса (шаг 4) и частичному индексу активных звонков.
- Проверка предусловий. Внешнего ввода нет. Допустимость состояния задаётся предикатами выборки: только
Ringing (фаза 1) / Answered (фаза 2); терминальные Missed/Ended/Declined/Failed не выбираются
(INV-CALL-3).
- Что читаем (claim под блокировкой). Захватить single-runner блокировку; если занята — прогон
пропустить. Иначе на каждый цикл — две выборки по частичному индексу активных звонков
ix_call_active
(status in ('Ringing','Answered')), порядок StartedAt asc, размер BatchSize, с пропуском уже
заблокированных строк:
- фаза
RingTimeout: Status == Ringing && StartedAt <= AsOf − RingTimeout;
- фаза
StaleAnswered: Status == Answered && UpdatedAt <= AsOf − StaleAnsweredTimeout.
Skip-locked пропускает строки, занятые командами answer-call/end-call, без ожидания.
- Бизнес-правила и переходы. Для каждой сессии:
- фаза 1:
Ringing → Missed (guard INV-CALL-3): Status = Missed, EndReason = Timeout, EndedAt = AsOf,
UpdatedAt = AsOf; поднимается CallMissedEvent;
- фаза 2:
Answered → Failed: Status = Failed, EndReason = Error, EndedAt = AsOf;
DurationSeconds = null (не Answered → Ended); поднимается CallEndedEvent.
Повторный проход над уже терминальным звонком — no-op.
- Что меняется. Обновляются
Status/EndReason/EndedAt/UpdatedAt затронутых сессий; в той же
транзакции на каждую закрытую сессию добавляется строка messenger.realtime_outbox
(Kind = CallMissed для фазы 1, Kind = CallEnded для фазы 2; TargetKind = User, адресаты
user:{CallerUserId}/user:{CalleeUserId}, только typed reference-id CallId/enum — без PII). Звонок —
append-only лог: единственный активный звонок и гонку закрывают guard-условие статуса + skip-locked
(INV-CALL-5). Переходы батча + outbox-строки фиксируются в одном согласованном изменении.
- Факты и события. Доменные
CallMissedEvent/CallEndedEvent (in-memory) диспетчеризуются при фиксации;
их обработчики пишут строку-намерение push в messenger.realtime_outbox (см. шаг 5). Фактическую доставку
CallMissed/CallEnded (non-PII: CallId/Status/MediaType) в хаб выполняет отдельный воркер
realtime-dispatch — persist-then-push: reaper только фиксирует намерение, никаких
синхронных вызовов хаба в цикле нет. RealtimeDispatchKind включает CallMissed (ранее reaper пушил его без
outbox-строки). Интеграционных Kafka-событий нет (call-session.md); SDP/ICE не сохраняются (INV-CALL-4).
- Согласованность. Граница — на батч: переходы батча + строки realtime-outbox фиксируются атомарно;
блокировки строк держатся до фиксации. Доставка в хаб — после фиксации, воркером realtime-dispatch
(persist-then-push); потеря push безопасна (клиент перезапросит REST). Poison-сессия (guard отклонил)
исключается, логируется, остаётся на следующий цикл.
- Результат. Счётчики цикла (
MissedCount, FailedCount, SkippedLockedCount, DurationMs) — для
метрик/логов, не HTTP. Инфраструктурный сбой → откат батча, освобождение блокировок, ретрай следующим
циклом (те же сессии ещё активны → выберутся снова, идемпотентно).
Граница транзакции
| Элемент | Правило |
|---|
| Единица работы | per batch — переходы батча + строки realtime-outbox фиксируются атомарно; блокировки строк живут до фиксации |
| Сохраняемое состояние | переходы CallSessionEntity (Status/EndReason/EndedAt/UpdatedAt) + строки messenger.realtime_outbox (Kind = CallMissed/CallEnded) в той же транзакции; Kafka-публикация не производится (событий нет) |
| External calls | синхронных внешних вызовов в цикле нет; hub-push выполняет воркер realtime-dispatch по outbox-строке — после фиксации (persist-then-push) |
| Частичная ошибка | continue — poison-сессия исключается и логируется, остальной батч фиксируется; инфраструктурный сбой — откат батча, ретрай следующим циклом |
Идемпотентность и retry
| Сценарий | Правило |
|---|
| Job restart / повторный цикл | Checkpoint не нужен: выборка отбирает только активные (Ringing/Answered); зафиксированный терминал невыбираем → повтор безопасен |
| Duplicate item (параллельные инстансы) | single-runner блокировка → один runner; при обходе skip-locked сессия достаётся одному, guard-условие статуса делает второй переход no-op |
Гонка reaper ↔ answer-call/end-call | Строка под командой заблокирована → skip-locked в этом цикле; если команда зафиксировала терминал/Answered до тика — сессия вне выборки |
| Повторяемая ошибка (инфраструктура) | Откат батча, блокировки сняты; следующий цикл повторяет (кандидаты ещё активны) |
| Poison item | Исключается из батча, ERROR-лог с CallId; не блокирует батч; при систематике — алерт по errors_total, разбор по runbook |
| Потеря hub-push | безопасна — realtime не источник истины; outbox-строка переживёт рестарт (realtime-dispatch доставит позже), а клиент при reconnect перезапрашивает журнал list-calls (REST) |
Наблюдаемость
| Тип | Правило |
|---|
| Logs | cycle started (jobRunId, AsOf); batch selected (phase, count); call reaped (CallId, Status, EndReason, CallerUserId, CalleeUserId); cycle completed (counts, DurationMs, SkippedLocked); item failed (CallId, exception). CallId/UserId — не PII |
| Metrics | messenger_call_reaper_run_total (counter — циклы, liveness); messenger_call_reaper_cycle_duration_seconds (histogram); messenger_call_reaper_items_total{outcome="missed"|"failed"}; messenger_call_reaper_active_backlog (gauge — активные звонки с превышенным окном); messenger_call_reaper_skip_locked_total; messenger_call_reaper_errors_total |
| Traces | correlationId = jobRunId; span цикла + per-item |
| Alerts | job не отработал > 2 интервалов (нет прироста run_total); active_backlog > 50 в течение 2 мин; errors_total rate > 0 устойчиво |
Связанный runbook
| Сценарий | Runbook |
|---|
| Worker стоит / backlog активных растёт | проверить hosted-service/pod, PollingInterval, застрявшую блокировку прогона — ../runbooks/call-timeout-reaper.md (будущий путь) |
| Poison-сессия не закрывается | разобрать LastError/статус; ручной end-call/корректировка статуса по runbook |
Тесты
| Тест | Ожидание |
|---|
| Empty batch | нет активных с превышенным окном — no-op success, мутаций/событий нет |
| Ring-timeout | Ringing, StartedAt <= now − 45с → Missed, EndReason=Timeout, строка realtime_outbox Kind=CallMissed в той же транзакции → realtime-dispatch пушит call_missed инициатору |
| Ответили в окне | Ringing → Answered до тика → вне выборки фазы 1, не трогается |
| Stale-answered | Answered, UpdatedAt старше StaleAnsweredTimeout → Failed, EndReason=Error, DurationSeconds=null, строка realtime_outbox Kind=CallEnded → push обеим сторонам |
| Идемпотентность | повторный цикл над уже Missed/Failed → guard no-op, второго события нет |
Гонка с answer-call | answer зафиксировал Answered до тика → сессия не в фазе 1; либо skip-locked в этом цикле |
| Параллельные инстансы | блокировка → один runner; при обходе skip-locked каждая сессия обработана один раз |
| Cancellation | остановка в цикле → батч докоммичен или откачен целиком, блокировки сняты, graceful stop |
| SLO точности | Ringing-звонок закрывается не позже StartedAt + RingTimeout + Interval |
Обратные ссылки
Автоссылки: где используется этот документ.
Связанные документы
- Дом звонка (переходы INV-CALL-1…5,
CallStatus/CallEndReason, ix_call_active, hub-push, SDP/ICE вне
хранилища): call-session.md; гейт контакта —
contact.md (INV-CALL-1), blacklist.md.
- Доставка push по outbox-строке (persist-then-push,
RealtimeDispatchKind): realtime-dispatch.md.
- Прецедент паттерна (polling, skip-locked, single-runner, timeout-reaper):
../../crm/background/booking-expiration.md,
../../user-activity/background/activity-closer.md.
- ADR: 0055 мини-мессенджер (сигналинг, SignalR),
0056 simplicity-first (polling/таймеры без Redis TTL),
0052 tenancy (System cross-tenant).
- Шаблон — background-job-template.