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

Жизненный цикл сессии звонка — 1:1 и конференция (messenger)

Сводный разбор жизненного цикла медиа-сессии контекста messenger: как создать/открыть, как подключить/добавить участников и как закрыть — для обеих моделей звонка: 1:1 (call-session.md) и групповой конференции в чате сделки (conference-session.md + ростер call-participant.md). Это гид: он ссылается на API-операции, состояния и события, но остаётся на контрактном уровне — дом полей, инвариантов и статусов живёт в domain-доках, детали операций — в api/.

Media-стек нейтрален (ADR-0062). Медиа (звук/видео/демонстрация экрана) идёт клиент ↔ SFU; бэкенд messenger не в медиа-плоскости: он ведёт факты жизненного цикла и ростер, а вход в комнату раздаёт дескриптором MediaJoinModel (комната + короткоживущий токен доступа). SDP/ICE и токены не персистятся (INV-CALL-4/6, INV-CONF-7). Media за флагами CALLS_MEDIA_ENABLED (1:1) / CONFERENCE_CALLS_ENABLED (конференции).

Назначение

Дать единую карту сессии, чтобы за один документ понять: какие состояния проходит звонок, какими командами он открывается и закрывается, кто и как входит/выходит из состава, и чем 1:1 отличается от конференции. Разбор нужен и фронту (что показывать по каждому переходу), и бэкенду (кто авторитет состава, кто закрывает осиротевшую комнату), и аналитику (какие события и куда публикуются).

Две модели рядом

Аспект1:1-звонокКонференция
АгрегатCallSessionEntity (messenger.call_sessions)ConferenceSessionEntity (messenger.conference_sessions)
Составфиксированная пара Caller/Calleeдинамический ростер CallParticipantEntity (owned-child)
Гейт доступаAccepted-контакт вне чёрного списка (CommunicationPolicy.CanCall, INV-CALL-1)активное членство в Deal-чате (record-level, INV-CHAT-4); отдельного контактного гейта нет
Ролинет ролей модерациировно один ConferenceRole = Moderator (INV-CONF-6), остальные Speaker
СтатусыRinging → Answered → Ended (+ Declined/Cancelled/Missed/Failed)Provisioning → Active → Ended (+ Failed)
КомнатаRoom (owned VO MediaRoom), минтится при → RingingRoom, резервируется при старте; admission подтверждает Provisioning → Active
Авто-завершениеring-timeout → Missed; stale → Failed (reaper)опустошение/stale/провизия → Ended/Failed (reaper)
Записьпо политике RecordingPolicy (в сделке)по политике (для сделки, как правило, включена)
Kafkaне публикуетpin.messenger.conference-ended.v1 (ключ DealId) при Ended/Failed
Reapercall-timeout-reaperconference-room-reaper
Авторитет составасам агрегат (пара фиксирована)presence-вебхук media-стека (jitsi-events.md, INV-CPART-5)

Общее для обеих моделей: persist-then-push (ADR-0055) — смена состояния и строка messenger.realtime_outbox пишутся в одной транзакции; после фиксации realtime-dispatch доставляет push и минтит MediaJoinModel.AccessToken в момент доставки (в БД/outbox токен не хранится). Комнаты в развёртывании не самоподнимаются — их допуск решает admission-вебхук (jitsi-reservation.md, fail-closed).

Создание / открытие сессии

1:1 — start-call

Инициатор зовёт Accepted-контакт. Проверяется CanCall (INV-CALL-1) и отсутствие активного звонка пары (INV-CALL-5), резервируется Room (RoomName детерминирован из id + nonce), по политике заводится запись Pending. Создаётся сессия Ringing, в той же транзакции — строка outbox CallInvited для callee. После фиксации callee получает CallInvite (комната + токен) в группу user:{CalleeUserId}; инициатор — свой Room в ответе 201.

Конференция — start-conference

Активный участник Deal-чата поднимает встречу. Проверяется Deal-тип и не-Closed чата (INV-CONF-1) и guard одной активной на чат (INV-CONF-2, ix_conf_active), резервируется Room, по политике заводится запись Pending. Создаётся сессия (Provisioning → Active) и строка ростера инициатора (ConferenceRole = Moderator, State = Joined, INV-CONF-6); ActiveParticipantCount = 1. В той же транзакции — намерение broadcast ConferenceInvite в группу chat:{ChatId}. Все активные участники чата получают приглашение с дескриптором входа.

Роль admission (Provisioning → Active). Комнату поднимает media-стек только с разрешения jitsi-reservation: mod_reservations спрашивает messenger по имени комнаты, первое успешное резервирование переводит конференцию Provisioning → Active (StartedAt). Пока admission не пришёл — конференция в Provisioning, вход невозможен (MSG_CONFERENCE_NOT_ACTIVE). 1:1-звонок провизии не требует — комната минтится сразу при → Ringing.

Подключение / добавление участников

1:1 — фиксированная пара

  • answer-call — только Callee: Ringing → Answered (AnsweredAt, старт отсчёта длительности), callee получает Room в ответ, обеим сторонам — CallSignal(Answered).
  • media-token — любой участник в Answered переполучает свежий MediaJoinModel (реконнект/истёкший токен). Состояние не меняется (Query-операция), комната и роль те же.

Конференция — четыре пути + presence-вебхук

Ростер меняют четыре команды, а авторитетно сверяет их серверный presence-вебхук (jitsi-events.md, INV-CPART-5):

ПутьОперацияПереход строки ростераRealtime
Сам входитjoin-conference[*]/Invited/Left → Joined (идемпотентная реактивация, INV-CPART-1)MediaParticipant(Joined)chat:{ChatId}, актору — свежий Room
Адресное приглашениеinvite-participantсоздаётся Invited для конкретных членов чатаадресный ConferenceInviteuser:{UserId} (тот же hub-метод, но targeted)
Отклонениеdecline-conferenceInvited → DeclinedMediaParticipant(Left) инициатору приглашения / в chat:{ChatId}
Удаление модераторомremove-participantJoined → Left (RemovedByModerator) + eject из комнатыMediaParticipant(Left)chat:{ChatId}

Авторитет ростера — presence-вебхук. OccupantJoined/OccupantLeft от media-стека upsert-ят строку ростера (создание/реактивация/soft-leave), пересчитывают ActiveParticipantCount/PeakParticipantCount и LastActivityAt. При расхождении с локальными командами выигрывает серверное событие (INV-CPART-5).

Реконнект, grace, двойное устройство. Разрыв сети переводит участника в Left c LeaveReason = Disconnected только после grace-окна без реконнекта; реконнект в пределах окна реактивирует ту же строку (Left → Joined, новый JoinedAt), дубля нет. Один пользователь с двух устройств — одна строка ростера (ux_call_participants_conf_user): второй вход реактивирует существующую. Вход не назначает модерацию — роль остаётся Speaker, если ранее не был модератором.

Закрытие сессии

Все пути завершения обеих моделей. Строка ростера при закрытии сессии soft-leave (Left, история сохраняется); сессия — терминальный статус. Kafka публикует только конференция (Ended/Failed).

ДействиеКтоПереходEndReasonHub-событиеДоменное / интеграционное
1:1
declineCallee в RingingRinging → DeclinedDeclinedCallSignal(Declined) обеимCallDeclinedEvent (Kafka нет)
cancelCaller в Ringing (end-call)Ringing → CancelledCancelledByCallerCallSignal(Cancelled) обеимCallCancelledEvent (Kafka нет)
timeoutcall-timeout-reaperRinging → MissedTimeoutCallSignal(Missed) / CallMissed инициаторуCallMissedEvent (Kafka нет)
hangupлюбая сторона в Answered (end-call)Answered → EndedHangupCaller/HangupCalleeCallSignal(Ended) обеимCallEndedEvent (Kafka нет)
signaling/stale errorсигналинг-слой / reaper (stale-answered)Ringing/Answered → FailedErrorCallSignal(Ended) / CallEndedCallEndedEvent (Kafka нет)
Конференция
leaveсам участник (leave-conference)ростер Joined → Left; сессия остаётся ActiveSelfLeft (строка)MediaParticipant(Left)chat:{ChatId}ConferenceParticipantLeftEvent (Kafka нет)
removeмодератор (remove-participant)ростер Joined → Left + ejectRemovedByModerator (строка)MediaParticipant(Left)chat:{ChatId}ConferenceParticipantLeftEvent (Kafka нет)
выход последнегопоследний активный вышелсессия остаётся Active до grace → закрывает reaperLastParticipantLeft (семантика) / EmptyTimeout (по факту reaper)— (закрытие даёт reaper)см. reaper-строку
moderator endмодератор (end-conference)Active → Ended (ростер закрыт ConferenceEnded)ModeratorEndedConferenceSignal(Ended)chat:{ChatId}ConferenceEndedEvent + conference-ended.v1
chat closedзакрытие Deal-чата (INV-CHAT-8)Active → EndedChatClosedConferenceSignal(Ended)ConferenceEndedEvent + conference-ended.v1
reaper empty/staleconference-room-reaperActive → EndedEmptyTimeout (пусто > grace) / Timeout (stale > cap)ConferenceSignal(Ended)ConferenceEndedEvent + conference-ended.v1
provision failreaper (admission не пришёл)Provisioning → FailedErrorопц. ConferenceSignal(Failed) ждавшимConferenceEndedEvent (без conference-ended)
fatal mediaсигналинг-слой / reaperActive/Provisioning → FailedErrorConferenceSignal(Ended)ConferenceEndedEvent + conference-ended.v1 (для Active → Failed)

Заметки к закрытию.

  • Выход последнего ≠ мгновенное завершение. leave-conference при опустошении оставляет сессию Active; фактически пустую комнату закрывает reaper через EmptyRoomGrace (EmptyTimeout, INV-CONF-8). Значение LastParticipantLeft зарезервировано доменом как причина штатного авто-завершения; в текущем right-sized-дизайне (ADR-0056) операционно закрывает reaper.
  • conference-ended.v1 публикуется и для Failed, когда конференция была Active (Active → Failed) — downstream (crm/notifications/dev-stats) получает факт завершения независимо от причины. Исключение — Provisioning → Failed (встречи не было): интеграционное событие не пишется, только внутренний алерт.
  • Остановка записи. Терминальный переход при активной записи запрашивает остановку рекордера (запись → Uploading); готовность бинаря придёт отдельным событием call-recording-available.md. Отказ media-стека не блокирует терминальный переход.
  • RoomDestroyed/DELETE резервирования не форсируют завершение: ростер помечается вышедшим, LastActivityAt обновляется, финал отдаётся reaper (grace) — чтобы не гонять с реконнектом.

Идемпотентность и гонки

СитуацияРазрешение
Двойной answer / decline (1:1)первый переход из Ringing побеждает; повторный видит не-RingingMSG_CALL_INVALID_STATE (идемпотентно проигрывает)
Glare — встречный вызов A→B и B→Aix_call_active гейтит по направленной паре, обе сессии формально допустимы; UI показывает обеим входящий, принявшая сторона отменяет свой исходящий (Cancelled)
Двойной старт конференции на чатpartial unique ix_conf_active пропускает один; проигравший — MSG_CONFERENCE_ALREADY_ACTIVE (INV-CONF-2)
Повторный join / реконнектреактивация той же строки ростера (INV-CPART-1), ActiveParticipantCount не задваивается, выдаётся свежий Room
Reaper ↔ штатное завершениеоба конкурируют за терминал; первый перевод побеждает (guard статуса на перечитанном состоянии), опоздавший — no-op / MSG_*_INVALID_STATE; событие публикуется один раз
Presence-вебхук ↔ команда join/leaveрасхождение разрешается в пользу серверного presence-события (INV-CPART-5); устаревшее по OccurredAt — no-op, состояние не откатывается
Двойной end-conferenceпервый — Ended + событие; повторный — MSG_CONFERENCE_INVALID_STATE, без повторного события/пересчёта
Повторная доставка conference-ended.v1ключ DealId; консюмеры идемпотентны по ConferenceId (first-seen)

Диаграммы

1. 1:1 — полный цикл со всеми ветками закрытия

2. Конференция — старт → broadcast + targeted invite → join(×N) → remove → закрытие

3. Реконнект участника конференции (Disconnected → grace → Joined)

Матрица «действие → эндпоинт → переход → hub → домен/Kafka»

ДействиеЭндпоинтПереходHub-событиеДомен / Kafka
Старт 1:1start-call[*] → RingingCallInviteuser:{Callee}CallStartedEvent
Ответ 1:1answer-callRinging → AnsweredCallSignal(Answered) обеимCallAnsweredEvent
Токен 1:1media-tokenбез изменения (Answered)— (Query)
Отклонение 1:1decline-callRinging → DeclinedCallSignal(Declined)CallDeclinedEvent
Отмена 1:1end-callRinging → CancelledCallSignal(Cancelled)CallCancelledEvent
Hangup 1:1end-callAnswered → EndedCallSignal(Ended)CallEndedEvent
Timeout 1:1call-timeout-reaperRinging → Missed / Answered → FailedCallMissed / CallEndedCallMissedEvent / CallEndedEvent
Старт конференцииstart-conferenceProvisioning → Activebroadcast ConferenceInvitechat:{ChatId}ConferenceStartedEvent
Admissionjitsi-reservationProvisioning → Active— (инвайты уже разосланы)
Присоединениеjoin-conferenceростер → JoinedMediaParticipant(Joined)ConferenceParticipantJoinedEvent
Приглашениеinvite-participantростер → Invitedtargeted ConferenceInviteuser:{UserId}ConferenceParticipantInvitedEvent
Отклонениеdecline-conferenceростер Invited → DeclinedMediaParticipant(Left)ConferenceParticipantDeclinedEvent
Выходleave-conferenceростер Joined → Left (SelfLeft)MediaParticipant(Left)ConferenceParticipantLeftEvent
Удалениеremove-participantростер Joined → Left (RemovedByModerator)MediaParticipant(Left) + ejectConferenceParticipantLeftEvent
Presencejitsi-eventsростер upsert / запись Pending → RecordingMediaParticipant/ScreenShareState/RecordingState— (авторитет ростера, INV-CPART-5)
Завершение модераторомend-conferenceActive → Ended (ModeratorEnded)ConferenceSignal(Ended)ConferenceEndedEvent + conference-ended.v1
Авто-завершениеconference-room-reaperActive → Ended / Provisioning → FailedConferenceSignal(Ended)ConferenceEndedEvent (+ conference-ended.v1 кроме Provisioning → Failed)
Закрытие чата(Deal-чат → Closed, INV-CHAT-8)Active → Ended (ChatClosed)ConferenceSignal(Ended)ConferenceEndedEvent + conference-ended.v1

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

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

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