Background Job: NotificationRetryWorker
Назначение
Фоновый worker повторяет отправку уведомлений в статусе Failed
(../domain/notification.md) через provider-адаптеры, а также отправляет
отложенные (ScheduledAt наступил) Pending. Реализует retry-through-polling (ADR-0056:
без внешних брокеров задержек) поверх периодической выборки готовых к отправке записей с пропуском строк,
уже взятых другим инстансом. Дополняет основной NotificationDispatcher (отправляет свежие Pending), беря
на себя повтор и дефер.
Классификация
| Поле | Значение |
|---|
| Сервис | notifications (Pin.Notifications.Background) |
| Тип job | Recurring |
| Trigger | cron-цикл (каждую 1 мин) |
| Критичность | medium (доставка внешних каналов; in-app уже персистнут) |
Расписание И Locking
| Аспект | Правило |
|---|
| Schedule | каждые 60с (UTC) |
| Распределённая блокировка | ключ notifications:retry-worker, ttl 90с, единственный владелец цикла (параллельные инстансы не запускают цикл одновременно) |
| Batch size | 200 записей/цикл (по NextAttemptAt/ScheduledAt) |
| Max duration | 45с/цикл (не перекрывать следующий tick) |
| Прерывание | кооперативная отмена на границе каждого шага (БД, provider-адаптер) — цикл завершается на границе записи |
Вход (параметры цикла)
| Поле | Тип | Обязательность | Источник | Описание |
|---|
TenantId | Guid? | Нет | не применяется (кросс-tenant scan разрешён явно — платформенная отправка) | Не фильтрует: worker сканирует все tenant-ы и инвесторов (null). |
Now | DateTimeOffset | Да | системное время | Порог NextAttemptAt <= Now / ScheduledAt <= Now. |
BatchSize | int | Да | конфиг | Размер батча (default 200). |
Алгоритм
- Захват цикла. Получить распределённую блокировку (
notifications:retry-worker) и корреляцию цикла; не
получил (цикл уже идёт на другом инстансе) — пропустить тик.
- Выборка готовых к отправке. Кросс-tenant scan явно разрешён (платформенная отправка). Взять батч
записей, готовых к диспатчу:
Status = Failed с наступившим NextAttemptAt и Attempts < MaxAttempts
(ожидают повтора) либо Status = Pending с наступившим ScheduledAt (отложенные тихими часами);
порядок — по NextAttemptAt/ScheduledAt, не более BatchSize. Конкурентные инстансы не берут одну запись
дважды: уже обрабатываемые строки пропускаются.
- Резолв канала. Для записи взять конфигурацию её канала (../domain/channel.md):
провайдер,
MaxAttempts, RetryBackoff, лимиты. Выключенный канал (IsEnabled = false) — оставить
Pending/Failed без попытки (skip, метрика channel-disabled), дождаться включения.
- Попытка отправки через 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).
- Чекпойнт — сам статус записи.
Status/Attempts/NextAttemptAt идемпотентны и служат курсором;
отдельного курсора нет — повторный прогон подхватит те же due-записи по их статусу.
- Публикация исхода.
NotificationDispatchedEvent уходит через транзакционный outbox атомарно со
сменой статуса записи (не может быть отправлено без зафиксированного исхода и наоборот).
- Завершение. Освободить блокировку; записать метрики (обработано/успех/повтор/терминальный отказ/лаг).
Согласованность и границы
| Элемент | Правило |
|---|
| Единица согласованности | по одной записи: статус каждого уведомления независим; сбой одной не откатывает батч |
| Сохраняемое состояние | 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 restart | resume по статусу (Failed/due Pending) — курсора нет, состояние в записи |
| Duplicate item | пропуск заблокированных строк + единственный владелец цикла исключают двойную обработку; провайдер дедуплицирует по NotificationId |
| Повторяемая ошибка | backoff по ChannelConfig.RetryBackoff (Fixed/Exponential); порог MaxAttempts |
| Poison item | Attempts >= MaxAttempts → терминальный Failed + алерт (ручной разбор), из цикла выбывает |
Наблюдаемость
| Тип | Правило |
|---|
| Logs | retry-cycle-started/item-sent/item-retried/item-failed-terminal/channel-disabled/cycle-completed (адрес/тело маскируются) |
| Metrics | notifications_retry_processed_total, notifications_retry_sent_total, notifications_retry_terminal_failed_total, notifications_failed_backlog_gauge, notifications_retry_cycle_duration_ms |
| Traces | correlationId, NotificationId, job run id, provider-span |
| Alerts | рост notifications_failed_backlog_gauge > 1000; всплеск терминальных Failed; провайдер недоступен (circuit open); цикл > 45с |
Связанный Runbook
| Сценарий | Runbook |
|---|
Провайдер недоступен (массовый Failed) | notifications/runbooks/provider-outage.md (будущий путь) |
Разбор терминальных Failed | notifications/runbooks/terminal-failures.md (будущий путь) |
Тесты
| Тест | Ожидание |
|---|
| Empty batch | no-op success (нет due-записей) |
| Retriable failure | Attempts++, NextAttemptAt по backoff, остаётся Failed |
| Max attempts reached | терминальный Failed + dispatched(Failed) + алерт |
| Disabled channel | skip без попытки (метрика channel-disabled) |
| Scheduled (quiet hours) due | Pending с наступившим ScheduledAt отправляется |
| Duplicate run (два инстанса) | единственный владелец цикла + пропуск заблокированных строк — без двойной отправки |
| Cancellation | graceful stop на границе item |
Обратные ссылки
Автоссылки: где используется этот документ.
Связанные документы