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

Background Job: RealtimeDispatchJob

Назначение

realtime-dispatch — фоновый воркер сервиса messenger, реализующий persist-then-push (ADR-0055): доставку уже зафиксированных в PostgreSQL событий (новое/правленое/удалённое сообщение message.md, read-receipt и member-события participant.md, системная лента chat-event.md, заявки/подтверждения контактов contact.md, сигналы 1:1-звонка call-session.md, а также media-события конференций conference-session.md/ростера call-participant.md и записи call-recording.md) подключённым клиентам через SignalR-hub /hubs/messengerидемпотентно и at-least-once.

Надёжность обеспечивает realtime-outbox messenger.realtime_outbox: строка-намерение push-а пишется в той же транзакции, что и доменная мутация (persist-then-push — сначала фиксация ленты/состояния, затем доставка). Строка несёт только typed reference-id (ChatId/MessageId/Seq/enum-Kind), не тело; диспетчер строит типизированный контракт RealtimeMessage на выдаче и пушит в hub-группу. Realtime не source of truth: при reconnect клиент перезапрашивает REST; потеря/дубль push безопасны, клиент дедупит по (ChatId, Seq) / MessageId. Механизм — polling без Redis-очереди для outbox (ADR-0056 п.3): claim строк под блокировкой (FOR UPDATE SKIP LOCKED), backoff, poison → карантин.

MetadataЗначение
Owning teamPIN Platform (Comms squad)
Статус документаdraft
Область действияворкер realtime-dispatch, таблица messenger.realtime_outbox, SignalR-hub /hubs/messenger (группы chat:{ChatId} / user:{UserId})
Assumptions / ограничениядоставка чата — в приватную группу chat:{ChatId} (только активные Participant, record-level); тело сообщения (PII) — только участникам чата, не в Kafka-события (message.md); offline-доставка (push/email) — отдельный путь через pin.messenger.message-posted.v1notifications
ЗависимостиPostgreSQL (messenger.realtime_outbox), SignalR-hub (Redis backplane при multi-replica — опция, REALTIME_BACKPLANE_ENABLED, ADR-0043/0055)
Источник требованийзаявлен в README «Background jobs / Runtime»

Классификация

ПолеЗначение
Сервисmessenger
Тип jobRecurring (near-real-time — короткий интервал + LISTEN/NOTIFY-wake)
Triggerтаймер каждые PollingInterval (default 1 с) + опц. PostgreSQL LISTEN realtime_outbox для немедленного пробуждения
Критичностьtier-1 (realtime UX чата; сбой → задержка in-app доставки, история не теряется — persisted, клиент перезапросит REST)

Расписание И Locking

АспектПравило
SchedulePollingInterval (default 1 с, UTC) + wake по LISTEN/NOTIFY при вставке строки outbox (низкая латентность без busy-loop)
Distributed lockprimary — single-runner: единый runner через distributed lock (ключ job:scheduled:messenger-realtime-dispatch, TTL LockTtl default 30 с, PostgreSQL advisory-backend); занятый lock → прогон пропускается
Row-levelclaim строк под блокировкой FOR UPDATE SKIP LOCKED (defense-in-depth) — при failover два runner-а берут непересекающиеся строки; двойной push идемпотентен (клиент дедупит)
Batch sizeBatchSize строк/проход (default 200); проход повторяет выборку, пока батч полон и не истёк MaxRunDuration
Max durationMaxRunDuration (default 5 с); остаток — следующим тиком/wake
Cancellationgraceful stop между строками при остановке хоста; закоммиченный прогресс сохраняется

Входная Модель

Внешнего runtime-входа нет: параметры — RealtimeDispatchOptions (bind + валидация на старте: PollingInterval, BatchSize > 0, MaxAttempts (default 8), backoff BackoffBase/BackoffFactor/BackoffCap (default 2 с / 2.0 / 2 мин + jitter), RetentionDone (default 24 ч — уборка Done)); рабочий вход — строки messenger.realtime_outbox.

Reliability-таблица RealtimeOutboxEntry (messenger.realtime_outbox)

Операционная структура job-а (не доменная сущность). Только typed reference-id и enum — без тела/PII.

ПолеТипОбязательностьОграниченияОписание
IdGuidДаPK (v7 — порядок вставки)Идентификатор push-намерения.
KindRealtimeDispatchKindДа.HasConversion<string>(), varchar(32)Тип push-а (см. enum).
TargetKindRealtimeTargetKindДа.HasConversion<string>(), varchar(16)ChatGroup (chat:{ChatId}) / User (user:{UserId}).
ChatIdGuid?Нетindex ix_rto_chatЧат-группа (для ChatGroup-push).
TargetUserIdGuid?НетАдресат (для User-push: контакт/звонок/блокировка).
MessageIdGuid?Нетлогическая ссылка messenger.messages(id)Сообщение (для MessageReceived/Edited/Deleted).
RefIdGuid?НетId связанной сущности (contact/call/chat-event/conference/recording/participant) для non-message push.
SessionKindSessionKind?Нет.HasConversion<string>(), varchar(12)Тип медиа-сессии для media-push (Direct — 1:1-звонок / Conference — конференция); задаёт, какой payload строит диспетчер (личный vs групповой). null для не-медиа push.
Seqlong?НетMessage.Seq — ключ дедупа клиента и порядка.
StatusRealtimeOutboxStatusДа.HasConversion<string>(), varchar(16), default PendingPending/Done/Quarantined.
AttemptsintДа>= 0, default 0Число попыток (инкремент при claim).
NextAttemptAtDateTimeOffsetДаindex ix_rto_due (status,next_attempt_at)Расписание попытки (backoff).
LastErrorstring?Нетvarchar(512)Последняя ошибка (класс+код, без PII).
CreatedAt/UpdatedAtDateTimeOffsetДаШтампы; CreatedAt — база SLO-лага.
RowVersionuintДаconcurrencyГонка claim/outcome.

Enum RealtimeOutboxStatus { Pending, Done, Quarantined }. Enum RealtimeDispatchKind { MessageReceived, MessageEdited, MessageDeleted, ReadReceiptUpdated, ChatUpdated, ContactRequestReceived, ContactRequestAccepted, ContactRemoved, CallInvited, CallAnswered, CallDeclined, CallMissed, CallCancelled, CallEnded, ConferenceInvited, ConferenceStarted, ConferenceEnded, ConferenceParticipantJoined, ConferenceParticipantLeft, RecordingStarted, RecordingStopped, RecordingAvailable, RecordingFailed, ScreenShareStarted, ScreenShareStopped, BlacklistChanged }. Media-значения (звонки/конференции/запись/ демонстрация экрана) добавлены под media-стек (ADR-0062) поверх существующих значений чата; CallMissed закрывает разрыв, при котором call-timeout-reaper пушил call_missed без outbox-строки. Actor — системный (вне tenant — доступ несёт членство чата/сессии); публичного входа нет.

Алгоритм

Push-вызов — между claim и фиксацией исхода (не держим блокировку строки на сетевом I/O к hub/backplane).

  1. Контекст прогона. Прогон запускает таймер (или NOTIFY); актор — системный, вне tenant. Отдельный idempotency-lookup не нужен: идемпотентность обеспечивают статус-фильтр + NextAttemptAt и клиентский дедуп по (ChatId, Seq)/MessageId.
  2. Проверка конфигурации. Внешнего runtime-входа нет; конфигурация проверена на старте (BatchSize > 0, MaxAttempts > 0, backoff > 0 — иначе fail-fast хоста).
  3. Захват работы (claim под блокировкой). Захватить single-runner-lock; занят → пропустить прогон. Иначе циклом батчей (до BatchSize строк, до MaxRunDuration) выбирать под блокировкой FOR UPDATE SKIP LOCKED строки outbox со статусом Pending, у которых NextAttemptAt <= now, в порядке вставки (индекс ix_rto_due, порядок по Id v7 = порядок фиксации). SKIP LOCKED разводит строки параллельного runner-а при failover.
  4. Claim-фаза. Для каждой claimed-строки: Attempts += 1, NextAttemptAt = now + backoff(Attempts); фиксация claim-фазы снимает блокировку строк. Пред-инкремент безопасен: краш до доставки даёт повтор после backoff.
  5. Сборка, доставка и исход. Для каждой строки построить типизированный контракт RealtimeMessage по Kind из reference-id (для MessageReceived — проекция сообщения chat:{ChatId}: { ChatId, MessageId, Seq, SenderUserId?, Kind, SentAt, Body/FileMeta/CardText } — тело только участникам приватной группы; для non-message — non-PII id/статус). Для media-push SessionKind строки выбирает форму payload: Direct — контракт 1:1-звонка (user:{TargetUserId}), Conference — конференции (chat:{ChatId}). Для приглашающих media-kind (CallInvited/ConferenceInvited/ConferenceStarted) диспетчер минтит MediaJoinModel (AccessToken, TURN/ICE-креды) на выдаче, при доставке — короткоживущие секреты никогда не пишутся в realtime_outbox (строка несёт только RoomName/reference-id); утечка строки не раскрывает доступ к комнате. Запушить: ChatGroup → в группу chat:{ChatId}; User → в user:{TargetUserId} (eventName snake_case). Затем зафиксировать исход: успех (2xx / enqueued в backplane) → Status = Done, LastError = null; transient (hub/backplane недоступен) → строка остаётся Pending (backoff из ш.4); poison (Attempts >= MaxAttempts) → Status = Quarantined + внутренний алерт RealtimeDispatchQuarantinedEvent.
  6. События. Интеграционных Kafka-событий job не публикует (доставка — hub, не Kafka). Событие RealtimeDispatchQuarantinedEvent ({ OutboxEntryId, Kind, ChatId?, Attempts }) — только внутренний алерт/метрика, вне claim-транзакции.
  7. Согласованность. Две короткие транзакции на строку: claim (Attempts/NextAttemptAt) и исход (Status/LastError); между ними — push вне открытой транзакции. Строка-намерение пишется в той же транзакции, что и доменная мутация владельца (send-message/edit/…/consumer — persist-then-push).
  8. Результат. Наружу — метрики прогона (dispatched/retried/quarantined); сбой на строке логируется и не рвёт проход (частичный прогресс закоммичен).

Граница Транзакции

ЭлементПравило
ТранзакцииДве короткие транзакции на строку: claim (Attempts/NextAttemptAt) и исход (Status/LastError); строка-намерение — в транзакции владельца (send-message/edit/consumer)
Сохраняемое состояниеmessenger.realtime_outbox (Status/Attempts/NextAttemptAt/LastError); доменное состояние не мутируется (read-only проекция на build)
External callspush к hub/backplane — между claim-tx и outcome-tx, вне открытой транзакции (нет row-lock/соединения на сетевом I/O)
Частичная ошибкаcontinue: сбой на строке логируется, не откатывает соседние; строка остаётся Pending (backoff) или Quarantined; следующий проход повторяет

Идемпотентность И Retry

СценарийПравило
Job restartCheckpoint не нужен — прогресс = Status; рестарт заново фильтрует Pending по NextAttemptAt
Параллельные инстансыdistributed lock → один runner; при failover-лаге FOR UPDATE SKIP LOCKED разводит строки, двойной push безопасен (клиент дедупит)
Дубль push (at-least-once)клиент дедупит по (ChatId, Seq) / MessageId; realtime не SoT — повтор не создаёт эффекта
Порядок доставки при retryДоставка не гарантирует межстрочный порядок: строка на backoff может уйти позже вставленной после неё. Порядок для пользователя определяется Seq — клиент сортирует историю чата по (ChatId, Seq), а не по времени push. Пропущенный из-за backoff Seq клиент дозапрашивает по REST (list-messages, SoT). Для media-push (звонок/конференция) порядок не критичен — важен факт события (invite/ended), клиент сверяет актуальное состояние REST-запросом.
Повторяемая ошибкаhub/backplane недоступен → Pending + экспоненциальный backoff (cap, jitter); pre-increment Attempts при claim
Poison itemAttempts >= MaxAttemptsQuarantined + алерт; исключён из выборки; при инциденте hub — массовый разбор по runbook
Потеря push (crash до outcome)безопасна: клиент при reconnect перезапрашивает list-messages/get-chat (REST, SoT); строка ещё Pending → повтор

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

ТипПравило
Logsbatch claimed (rows); dispatched (Kind, ChatId/TargetUserId, Seq, Attempts); dispatch retry (EntryId, Attempts, errorCode); quarantined (EntryId, Kind); job completed (dispatched, retried, quarantined, DurationMs). Тело/PII в логи не пишется
Metricsmessenger_realtime_pending (gauge — backlog Pending); messenger_realtime_lag_seconds (histogram — now − CreatedAt на Done, SLO persist→push); messenger_realtime_dispatched_total; messenger_realtime_retried_total; messenger_realtime_quarantined_total; hub disconnect rate (README)
Tracesspan на проход (jobRunId), вложенный span на диспетчеризацию строки (Kind, ChatId); корреляция с owner-tx через CreatedAt/RefId
Alertsвсплеск ошибок realtime-dispatch / рост pending (README «Alerts»); lag_seconds p95 > 1 с (нарушение SLO); quarantined_total > 0; отсутствие прогонов > 1 мин

SLO

ПоказательЦельОбоснование
Лаг persist → pushp95 < 1 с (README SLO)PollingInterval 1 с + NOTIFY-wake; доступный hub
Steady-state backlogmessenger_realtime_pending ≈ 0 вне инцидента hubдиспетчер успевает за фиксацией
Идемпотентность0 видимых дублей у клиента при рестартах/N инстансахклиентский дедуп (ChatId, Seq) + фильтр

Связанный Runbook

СценарийRunbook
pending/lag растётпроверить доступность hub/backplane (REALTIME_BACKPLANE_ENABLED), disconnect rate; после восстановления backlog разгребётся — ../runbooks/realtime-dispatch.md (будущий путь)
quarantined_total > 0разобрать LastError; ручной re-enqueue (Quarantined → Pending, Attempts = 0) после устранения причины
Worker стоит (нет прогонов)проверить hosted-service/pod, PollingInterval, застрявший distributed lock

Тесты

ТестОжидание
Успешная доставкаPending (MessageReceived) → push в chat:{ChatId}Status=Done, dispatched_total++
Пустой проходнет Pending с NextAttemptAt <= now — no-op success, вызовов нет
Недоступность hub → повторfail → строка Pending, Attempts++, NextAttemptAt сдвинут по backoff; следующий проход повторяет
Дедуп клиентадвойная доставка одного (ChatId, Seq) → клиент показывает одно сообщение
Poison → карантинAttempts >= MaxAttemptsQuarantined + RealtimeDispatchQuarantinedEvent/алерт
Media-token at deliveryConferenceInvited (SessionKind=Conference) → MediaJoinModel.AccessToken/TURN минтятся при доставке; в realtime_outbox секретов нет (только RoomName/reference-id)
SessionKind payloadCallInvited c SessionKind=Direct → 1:1-контракт в user:{Target}; Conference → групповой контракт в chat:{ChatId}
Персист-then-pushcrash между owner-commit и push → строка Pending переживает рестарт, доставляется позже; клиент видит историю по REST
Параллельные инстансыlock → один runner; при обходе FOR UPDATE SKIP LOCKED не даёт двойного claim одной строки
Cancellationотмена между строками — graceful stop, claim-транзакция не рвётся, закоммиченное сохранено

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

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

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