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

Background Job: NotificationRetryWorker

Назначение

Фоновый worker повторяет отправку уведомлений в статусе Failed (../domain/notification.md) через provider-адаптеры, а также отправляет отложенные (ScheduledAt наступил) Pending. Реализует retry-through-polling (ADR-0056: без внешних брокеров задержек) поверх периодической выборки готовых к отправке записей с пропуском строк, уже взятых другим инстансом. Дополняет основной NotificationDispatcher (отправляет свежие Pending), беря на себя повтор и дефер.

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

ПолеЗначение
Сервисnotifications (Pin.Notifications.Background)
Тип jobRecurring
Triggercron-цикл (каждую 1 мин)
Критичностьmedium (доставка внешних каналов; in-app уже персистнут)

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

АспектПравило
Scheduleкаждые 60с (UTC)
Распределённая блокировкаключ notifications:retry-worker, ttl 90с, единственный владелец цикла (параллельные инстансы не запускают цикл одновременно)
Batch size200 записей/цикл (по NextAttemptAt/ScheduledAt)
Max duration45с/цикл (не перекрывать следующий tick)
Прерываниекооперативная отмена на границе каждого шага (БД, provider-адаптер) — цикл завершается на границе записи

Вход (параметры цикла)

ПолеТипОбязательностьИсточникОписание
TenantIdGuid?Нетне применяется (кросс-tenant scan разрешён явно — платформенная отправка)Не фильтрует: worker сканирует все tenant-ы и инвесторов (null).
NowDateTimeOffsetДасистемное времяПорог NextAttemptAt <= Now / ScheduledAt <= Now.
BatchSizeintДаконфигРазмер батча (default 200).

Алгоритм

  1. Захват цикла. Получить распределённую блокировку (notifications:retry-worker) и корреляцию цикла; не получил (цикл уже идёт на другом инстансе) — пропустить тик.
  2. Выборка готовых к отправке. Кросс-tenant scan явно разрешён (платформенная отправка). Взять батч записей, готовых к диспатчу: Status = Failed с наступившим NextAttemptAt и Attempts < MaxAttempts (ожидают повтора) либо Status = Pending с наступившим ScheduledAt (отложенные тихими часами); порядок — по NextAttemptAt/ScheduledAt, не более BatchSize. Конкурентные инстансы не берут одну запись дважды: уже обрабатываемые строки пропускаются.
  3. Резолв канала. Для записи взять конфигурацию её канала (../domain/channel.md): провайдер, MaxAttempts, RetryBackoff, лимиты. Выключенный канал (IsEnabled = false) — оставить Pending/Failed без попытки (skip, метрика channel-disabled), дождаться включения.
  4. Попытка отправки через provider-адаптер (timeout per-канал):
    • успех → Sent (сохраняется ProviderMessageId); эмит NotificationDispatchedEvent(Sent) (../events/notification-dispatched.md);
    • retriable-ошибка (таймаут/5xx/throttle провайдера) → Failed, Attempts++, NextAttemptAt = now + backoff(Attempts) по RetryBackoff (Fixed/Exponential); при достижении Attempts >= MaxAttempts (INV-NTF-7) — терминальный Failed + эмит NotificationDispatchedEvent(Failed) + алерт;
    • non-retriable (невалидный адрес/токен/отозванный шаблон) → сразу терминальный Failed без повтора + NotificationDispatchedEvent(Failed). Переходы — только допустимые INV-NTF-5 (Failed→Sent/Failed→Failed/Pending→Sent/Pending→Failed).
  5. Чекпойнт — сам статус записи. Status/Attempts/NextAttemptAt идемпотентны и служат курсором; отдельного курсора нет — повторный прогон подхватит те же due-записи по их статусу.
  6. Публикация исхода. NotificationDispatchedEvent уходит через транзакционный outbox атомарно со сменой статуса записи (не может быть отправлено без зафиксированного исхода и наоборот).
  7. Завершение. Освободить блокировку; записать метрики (обработано/успех/повтор/терминальный отказ/лаг).

Согласованность и границы

ЭлементПравило
Единица согласованностипо одной записи: статус каждого уведомления независим; сбой одной не откатывает батч
Сохраняемое состояниеNotificationEntity (статус/Attempts/NextAttemptAt/ProviderMessageId) + факт dispatched — в одном согласованном изменении
Внешние вызовыprovider-адаптер вызывается до фиксации статуса; идемпотентность отправки — по NotificationId (dedup/collapse на стороне провайдера)
Частичная ошибкаcontinue: сбойная запись помечается Failed и повторится в следующем цикле

Пред- и постусловия

  • Предусловие. Существуют due-записи: Failed с наступившим NextAttemptAt и Attempts < MaxAttempts, либо Pending с наступившим ScheduledAt. Канал включён (IsEnabled = true) — иначе запись ждёт включения.
  • Постусловие цикла. Каждая обработанная запись переведена в Sent (успех) или Failed (повтор запланирован через NextAttemptAt, либо терминальный при Attempts >= MaxAttempts); по финальным исходам опубликован NotificationDispatchedEvent; блокировка отпущена; метрики обновлены. Записи, не попавшие в батч, ждут следующего тика (гарантия прогресса — за счёт сортировки по времени готовности).

Кросс-сервисные контракты

  • Ожидает от каталога каналов (../domain/channel.md): по каналу записи — IsEnabled, ProviderKey, MaxAttempts, RetryBackoff, лимиты; отсутствие/выключенность канала — не отправлять.
  • Ожидает от provider-адаптера (../providers/): идемпотентную по NotificationId отправку и классификацию ошибки retriable/non-retriable; сейчас все внешние каналы — заглушки (per-канал feature-флаг), реально доставляется только in-app.
  • Предоставляет аналитике доставляемости pin.notifications.dispatched.v1 (key = NotificationId, ContractVersion = 1) при Sent/терминальном Failed — промежуточные Failed события не порождают (снижение шума, ../events/notification-dispatched.md).

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

СценарийПравило
Job restartresume по статусу (Failed/due Pending) — курсора нет, состояние в записи
Duplicate itemпропуск заблокированных строк + единственный владелец цикла исключают двойную обработку; провайдер дедуплицирует по NotificationId
Повторяемая ошибкаbackoff по ChannelConfig.RetryBackoff (Fixed/Exponential); порог MaxAttempts
Poison itemAttempts >= MaxAttempts → терминальный Failed + алерт (ручной разбор), из цикла выбывает

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

ТипПравило
Logsretry-cycle-started/item-sent/item-retried/item-failed-terminal/channel-disabled/cycle-completed (адрес/тело маскируются)
Metricsnotifications_retry_processed_total, notifications_retry_sent_total, notifications_retry_terminal_failed_total, notifications_failed_backlog_gauge, notifications_retry_cycle_duration_ms
TracescorrelationId, NotificationId, job run id, provider-span
Alertsрост notifications_failed_backlog_gauge > 1000; всплеск терминальных Failed; провайдер недоступен (circuit open); цикл > 45с

Связанный Runbook

СценарийRunbook
Провайдер недоступен (массовый Failed)notifications/runbooks/provider-outage.md (будущий путь)
Разбор терминальных Failednotifications/runbooks/terminal-failures.md (будущий путь)

Тесты

ТестОжидание
Empty batchno-op success (нет due-записей)
Retriable failureAttempts++, NextAttemptAt по backoff, остаётся Failed
Max attempts reachedтерминальный Failed + dispatched(Failed) + алерт
Disabled channelskip без попытки (метрика channel-disabled)
Scheduled (quiet hours) duePending с наступившим ScheduledAt отправляется
Duplicate run (два инстанса)единственный владелец цикла + пропуск заблокированных строк — без двойной отправки
Cancellationgraceful stop на границе item

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

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

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