Background Job: ConferenceRoomReaperJob
Назначение
ConferenceRoomReaperJob — периодический фоновый worker сервиса messenger, закрывающий осиротевшие и
«зависшие» конференции ConferenceSessionEntity, которые остались в
активных статусах (Provisioning/Active), потому что штатное завершение не наступило: последний участник
отвалился без корректного leave; комната не поднялась в media-стеке; presence-heartbeat'ы перестали приходить.
Обрабатывает три фазы над одним агрегатом:
- empty-room (INV-CONF-8) —
Status = Active, ActiveParticipantCount = 0 дольше EmptyRoomGrace
(default 90 с) → Ended, EndReason = EmptyTimeout;
- provision-timeout —
Status = Provisioning старше ProvisionTimeout (default 2 мин; media-стек не
подтвердил admission) → Failed, EndReason = Error;
- stale-live —
Status = Active без presence-активности (LastActivityAt <= now − StaleLiveTimeout,
default 4 ч; heartbeat'ы пропали, но комната не опустела формально) → Ended, EndReason = Timeout.
Все переходы терминальны (INV-CONF-4). Для Active → Ended/Failed (фазы empty-room и stale-live)
поднимается доменное ConferenceEndedEvent и в той же транзакции пишутся: строка транзакционного outbox
интеграционного события pin.messenger.conference-ended.v1 (ключ DealId) и
строка messenger.realtime_outbox (Kind = ConferenceEnded) для hub-push ConferenceSignal(Ended)
(persist-then-push через realtime-dispatch). Фаза provision-timeout
(Provisioning → Failed) не публикует conference-ended: конференция так и не стала Active, встречи не
было — есть только внутренний алерт. Если у закрываемой сессии есть активная запись
(call-recording.md), reaper запрашивает остановку рекордера media-стека;
финализацию бинаря доводит собственный путь записи (сеть безопасности — recording-finalize-retry).
Медиа/SDP/ICE/токены доступа не персистятся (INV-CONF-7 — в хранилище только факты жизненного цикла и
ростер). Цикл — polling без Redis/TTL (ADR-0056 п.3): выборка активных конференций по частичному индексу
ix_conf_active с single-runner блокировкой; переход фиксируется атомарно, идемпотентно (guard-условие статуса
Provisioning/Active).
| Metadata | Значение |
|---|
| Owning team | PIN Platform (Comms squad) |
| Статус документа | draft |
| Область действия | worker conference-room-reaper, таблица messenger.conference_sessions (+ messenger.realtime_outbox, транзакционный outbox pin.messenger.conference-ended.v1); остановка записи — сигнал рекордеру media-стека; доставка в хаб — realtime-dispatch |
| Assumptions / ограничения | групповые звонки редки на целевой нагрузке (ADR-0056); happy-path завершения несёт end-conference, reaper — сеть безопасности для осиротевших/зависших комнат; точность закрытия пустой комнаты — EmptyRoomGrace + Interval; media за флагом CONFERENCE_CALLS_ENABLED |
| Зависимости | PostgreSQL (messenger.conference_sessions, messenger.realtime_outbox), Kafka (публикация pin.messenger.conference-ended.v1), media-стек (сигнал остановки рекордера) |
Классификация
| Поле | Значение |
|---|
| Сервис | messenger |
| Тип job | периодический (PeriodicTimer) |
| Trigger | таймер каждые PollingInterval (default 30 с — низкий, ради точности окна EmptyTimeout) |
| Критичность | tier-2 (UX конференций сделки + освобождение комнат media-стека; сбой → «висящая» комната и лишняя карточка в UI, не потеря данных) |
Расписание и locking
| Аспект | Правило |
|---|
| Schedule | фиксированный PollingInterval (default 30 с, UTC); тик не стартует поверх идущего цикла |
| Distributed lock | single-runner: попытка захватить блокировку прогона без ожидания, TTL LockTtl (default 2 мин); backend — PostgreSQL advisory. Блокировка занята → прогон пропускается |
| Row-level | claim-выборка с пропуском уже заблокированных строк (skip-locked, defense-in-depth); переход идемпотентен по guard-условию статуса |
| Batch size | BatchSize конференций/цикл на фазу (default 100); backlog добирается циклами (порядок по LastActivityAt asc / CreatedAt asc) |
| Max duration | MaxRunDuration (default 30 с; цель < 5 с/цикл) |
| Cancellation | остановка хоста учитывается на каждом обращении к хранилищу; текущий batch докоммичивается или откатывается целиком, блокировки снимаются |
Входная модель
Внешнего runtime-входа нет; параметры — ConferenceRoomReaperOptions (валидация на старте).
| Поле | Тип | Обязательность | Источник | Описание |
|---|
AsOf | DateTimeOffset | Да | часы системы | Момент цикла для сравнения с LastActivityAt/UpdatedAt/CreatedAt. |
EmptyRoomGrace | TimeSpan | Да | ConferenceRoomReaperOptions (default 90 с) | Grace для Active с ActiveParticipantCount = 0 → EmptyTimeout (INV-CONF-8). |
ProvisionTimeout | TimeSpan | Да | ConferenceRoomReaperOptions (default 2 мин) | Provisioning дольше → Failed(Error) (admission не пришёл). |
StaleLiveTimeout | TimeSpan | Да | ConferenceRoomReaperOptions (default 4 ч) | Active без presence-активности дольше → Ended(Timeout). |
BatchSize | int | Да | ConferenceRoomReaperOptions (default 100) | Верхняя граница конференций за фазу за цикл. |
TenantId | TenantId | Нет | не применяется | Cross-tenant scan: платформенный System-worker без tenant-фильтра (доступ к конференции — членство в Deal-чате, не строковый tenant; ADR-0052 п.3). |
Алгоритм
Каждый тик обрабатывает три фазы по порядку — empty-room, provision-timeout, stale-live (переход в
терминал делает конференцию невыбираемой в остальных фазах).
- Контекст и доступ. Источник запуска — расписание; актор — System (scope-фильтры отключены, cross-tenant
разрешён явно);
correlationId = jobRunId; AsOf — текущее время. Идемпотентность — не по конверту
события, а по guard-условию статуса (шаг 4) и частичному индексу активных конференций ix_conf_active.
- Проверка предусловий. Внешнего ввода нет. Допустимость состояния задаётся предикатами выборки: только
Provisioning/Active; терминальные Ended/Failed не выбираются (INV-CONF-4).
- Что читаем (claim под блокировкой). Захватить single-runner блокировку; если занята — прогон
пропустить. Иначе на каждый цикл — три выборки по частичному индексу
ix_conf_active
(status in ('Provisioning','Active')), размер BatchSize, с пропуском уже заблокированных строк:
empty-room: Status == Active && ActiveParticipantCount == 0 && LastActivityAt <= AsOf − EmptyRoomGrace;
provision-timeout: Status == Provisioning && CreatedAt <= AsOf − ProvisionTimeout;
stale-live: Status == Active && LastActivityAt <= AsOf − StaleLiveTimeout.
Skip-locked пропускает строки, занятые командами join/leave/end-conference и admission-вебхуком, без
ожидания.
- Бизнес-правила и переходы. Для каждой сессии (guard INV-CONF-4):
empty-room: Active → Ended, EndReason = EmptyTimeout, EndedAt = AsOf,
DurationSeconds = AsOf − StartedAt; поднимается ConferenceEndedEvent;
stale-live: Active → Ended, EndReason = Timeout (аналогично, для «зависшей» живой комнаты);
provision-timeout: Provisioning → Failed, EndReason = Error, EndedAt = AsOf (комната не поднялась;
StartedAt не задавался, встречи не было).
Повторный проход над уже терминальной конференцией — no-op. Если у сессии есть активная запись
(Pending/Recording/Uploading) — сигнал рекордеру на остановку (идемпотентно).
- Что меняется. Обновляются
Status/EndReason/EndedAt/DurationSeconds/UpdatedAt затронутых
конференций. В той же транзакции для завершений Active → Ended: строка транзакционного outbox
pin.messenger.conference-ended.v1 + строка messenger.realtime_outbox (Kind = ConferenceEnded,
TargetKind = ChatGroup, ChatId, SessionKind = Conference — только reference-id/enum, без PII). Для
Provisioning → Failed интеграционное событие не пишется (встречи не было); realtime-строка — опционально
ConferenceEnded/ConferenceSignal(Failed) для клиентов, ждавших admission. Переходы батча + outbox-строки
фиксируются в одном согласованном изменении.
- Факты и события. Доменное
ConferenceEndedEvent (in-memory) диспетчеризуется при фиксации. Интеграционное
pin.messenger.conference-ended.v1 (ключ/AggregateId = DealId, payload { ConferenceId, ChatId, DealId, TenantId, MediaType, Status, EndReason, StartedByUserId, StartedAt, EndedAt, DurationSeconds, PeakParticipantCount, Participants[], WasRecorded, RecordingId? }, без ФИО/медиа) — через транзакционный
outbox; контракт conference-ended.md, консюмеры crm (карточка встречи в
таймлайне)/notifications/dev-stats. Hub-push ConferenceSignal(Ended) — по realtime-строке воркером
realtime-dispatch. Готовность возможной записи придёт отдельным событием
call-recording-available.md.
Окно согласованности и промежуточное состояние: conference-ended (с флагом WasRecorded и, если
запись велась, RecordingId) публикуется сразу при закрытии комнаты, а call-recording-available — только
после финализации бинаря (сигнал рекордеру здесь best-effort, финализацию доводит
recording-finalize-retry). Порядок для консюмера crm: сначала карточка
встречи в таймлайне помечается «запись обрабатывается», затем — при отдельном событии готовности — к ней
привязывается доступная запись; окно между ними — от секунд до минут (зависит от размера файла и выгрузки).
- Согласованность. Граница — на батч: переходы + строки outbox (Kafka и realtime) фиксируются атомарно;
блокировки строк держатся до фиксации. Kafka-доставка — после фиксации (at-least-once, outbox-dispatcher);
hub-push — после фиксации (realtime-dispatch). Poison-сессия (guard отклонил) исключается, логируется,
остаётся на следующий цикл.
- Результат. Счётчики цикла (
EmptyTimeoutCount, ProvisionFailedCount, StaleTimeoutCount,
SkippedLockedCount, DurationMs) — для метрик/логов, не HTTP. Инфраструктурный сбой → откат батча,
освобождение блокировок, ретрай следующим циклом (кандидаты ещё активны → выберутся снова, идемпотентно).
Граница транзакции
| Элемент | Правило |
|---|
| Единица работы | per batch — переходы батча + строки outbox (Kafka + realtime) фиксируются атомарно; блокировки строк живут до фиксации |
| Сохраняемое состояние | переходы ConferenceSessionEntity (Status/EndReason/EndedAt/DurationSeconds/UpdatedAt) + транзакционный outbox pin.messenger.conference-ended.v1 (для Active → Ended) + messenger.realtime_outbox (Kind = ConferenceEnded) |
| External calls | синхронных внешних вызовов в цикле нет; Kafka-публикация и hub-push — после фиксации (outbox / realtime-dispatch); сигнал рекордеру — best-effort вне транзакции (финализацию доводит путь записи) |
| Частичная ошибка | continue — poison-сессия исключается и логируется, остальной батч фиксируется; инфраструктурный сбой — откат батча, ретрай следующим циклом |
Идемпотентность и retry
| Сценарий | Правило |
|---|
| Job restart / повторный цикл | Checkpoint не нужен: выборка отбирает только активные (Provisioning/Active); зафиксированный терминал невыбираем → повтор безопасен |
| Duplicate item (параллельные инстансы) | single-runner блокировка → один runner; при обходе skip-locked сессия достаётся одному, guard-условие статуса делает второй переход no-op |
Гонка reaper ↔ join/leave/end-conference/admission | Строка под командой/вебхуком заблокирована → skip-locked в этом цикле; если участник вошёл (ActiveParticipantCount > 0) или команда завершила конференцию до тика — сессия вне выборки |
| Повторяемая ошибка (инфраструктура) | Откат батча, блокировки сняты; следующий цикл повторяет (кандидаты ещё активны) |
Re-emit conference-ended | ключ DealId; консюмеры идемпотентны по ConferenceId (first-seen) — повтор безопасен |
| Poison item | Исключается из батча, ERROR-лог с ConferenceId; не блокирует батч; при систематике — алерт по errors_total, разбор по runbook |
| Потеря hub-push | безопасна — realtime не источник истины; outbox-строка переживёт рестарт; клиент при reconnect перезапрашивает get-conference/list-conferences (REST) |
Наблюдаемость
| Тип | Правило |
|---|
| Logs | cycle started (jobRunId, AsOf); batch selected (phase, count); conference reaped (ConferenceId, ChatId, DealId, Status, EndReason, DurationSeconds); cycle completed (counts, DurationMs, SkippedLocked); item failed (ConferenceId, exception). ConferenceId/ChatId/DealId — не PII |
| Metrics | messenger_conf_reaper_run_total (counter — циклы, liveness); messenger_conf_reaper_cycle_duration_seconds (histogram); messenger_conf_reaper_items_total{outcome="empty_timeout"|"provision_failed"|"stale_timeout"}; messenger_conf_reaper_active_backlog (gauge — активные конференции с превышенным окном); messenger_conf_reaper_skip_locked_total; messenger_conf_reaper_errors_total |
| Traces | correlationId = jobRunId; span цикла + per-item (ConferenceId, phase); DealTenantId per item |
| Alerts | job не отработал > 2 интервалов (нет прироста run_total); active_backlog > 20 в течение 2 мин; provision_failed rate ↑ (проблема admission media-стека); errors_total rate > 0 устойчиво |
SLO
| Показатель | Цель | Обоснование |
|---|
| Закрытие пустой комнаты | Active/ActiveParticipantCount=0 закрыт не позже LastActivityAt + EmptyRoomGrace + PollingInterval | освобождение комнаты media-стека и корректная карточка встречи |
| Provision-timeout | «неподнявшаяся» Provisioning терминализуется в пределах ProvisionTimeout + PollingInterval | клиент не ждёт admission бесконечно |
| Steady-state backlog | messenger_conf_reaper_active_backlog ≈ 0 вне инцидента media-стека | reaper успевает за завершениями |
Связанный runbook
| Сценарий | Runbook |
|---|
| Worker стоит / backlog активных растёт | проверить hosted-service/pod, PollingInterval, застрявшую блокировку прогона — ../runbooks/conference-room-reaper.md (будущий путь) |
Рост provision_failed | проверить доступность media-стека/admission-вебхука (internal); после восстановления backlog разгребётся |
| Poison-сессия не закрывается | разобрать LastError/статус; ручной end-conference/корректировка статуса по runbook |
Тесты
| Тест | Ожидание |
|---|
| Empty batch | нет активных с превышенным окном — no-op success, мутаций/событий нет |
| Empty-room | Active, ActiveParticipantCount=0, LastActivityAt <= now − 90с → Ended, EndReason=EmptyTimeout, conference-ended (Kafka) + ConferenceEnded (realtime) |
| Кто-то вошёл в grace | ActiveParticipantCount > 0 до тика → вне выборки empty-room, не трогается |
| Provision-timeout | Provisioning старше ProvisionTimeout → Failed, EndReason=Error, без conference-ended |
| Stale-live | Active, LastActivityAt старше StaleLiveTimeout → Ended, EndReason=Timeout, событие опубликовано |
| Активная запись | у закрываемой конференции запись Recording → сигнал остановки рекордеру; финализацию доводит путь записи |
| Идемпотентность | повторный цикл над уже Ended/Failed → guard no-op, второго события нет |
Гонка с end-conference | команда завершила до тика → сессия вне выборки; либо skip-locked в этом цикле |
| Параллельные инстансы | блокировка → один runner; при обходе skip-locked каждая сессия обработана один раз |
| Cancellation | остановка в цикле → батч докоммичен или откачен целиком, блокировки сняты, graceful stop |
| SLO точности | пустая комната закрывается не позже LastActivityAt + EmptyRoomGrace + Interval |
Обратные ссылки
Автоссылки: где используется этот документ.
Связанные документы
- Дом конференции (переходы INV-CONF-1…8,
ConferenceStatus/ConferenceEndReason, ix_conf_active,
ActiveParticipantCount/LastActivityAt, media/токены вне хранилища): conference-session.md;
ростер — call-participant.md; запись — call-recording.md.
- Доставка push по outbox-строке (persist-then-push,
RealtimeDispatchKind): realtime-dispatch.md.
- Событие завершения: ../events/conference-ended.md; штатное завершение (happy-path):
../api/conferences/end-conference.md.
- Прецедент паттерна (polling, skip-locked, single-runner, timeout-reaper): call-timeout-reaper.md,
../../crm/background/booking-expiration.md.
- ADR: 0062 media-звонки и запись,
0055 мини-мессенджер (persist-then-push, SignalR),
0056 simplicity-first (polling/таймеры без Redis TTL),
0052 tenancy (System cross-tenant).
- Шаблон — background-job-template.