Background Job: ReminderDispatchJob
Назначение
ReminderDispatchJob — фоновый polling-worker контекста calendar (reminder-dispatch), диспетчер
напоминаний: выбирает наступившие reminder-строки (Pending, DueAt < now,
event-reminder.md), переводит их в Sent и атомарно публикует
pin.calendar.reminder-due.v1 — доставку по каналам выполняет контекст notifications. Строки событий,
успевших завершиться или отмениться, переводятся в Skipped без публикации (fail-safe поверх каскадов
события). Механизм — материализованные строки + polling без Redis/TTL
(ADR-0064 п.5,
ADR-0056): выборка кандидатов под
построчной блокировкой SKIP LOCKED, переход + outbox атомарно в одной записи.
| Поле | Значение |
|---|
| Owner | PIN Platform (backend) |
| Статус документа | draft |
| Область действия | worker reminder-dispatch, reminder-строки контекста calendar, топик pin.calendar.reminder-due.v1 |
| Assumptions / ограничения | целевая нагрузка — десятки тысяч Pending-строк на горизонте (ADR-0056); точность доставки ≤ 2 мин от DueAt (README, SLO) |
| Зависимости | PostgreSQL (reminder-строки, события, outbox), Kafka (outbox-publisher) |
Классификация
| Поле | Значение |
|---|
| Сервис | calendar |
| Тип job | Recurring (polling-worker) |
| Trigger | цикл 60 с (без внешнего триггера/топика) |
| Критичность | tier-2 (пропуск напоминания не ломает данные; страдает UX встреч и дедлайнов) |
Расписание и Locking
| Аспект | Правило |
|---|
| Schedule | каждые 60 с (UTC-часы); следующий тик не стартует, пока идёт предыдущий цикл (без наложений) |
| Distributed lock | нет глобального lock (ADR-0056 — без Redis); параллельные инстансы координируются построчной блокировкой SKIP LOCKED — каждая строка достаётся ровно одному инстансу |
| Batch size | 200 строк/цикл (ReminderDispatchOptions.BatchSize); backlog добирается следующими циклами (order by DueAt asc) |
| Max duration | цель < 30 с/цикл; при превышении — тик пропускается, добор в следующем цикле |
| Cancellation | остановка хоста проходит в каждый I/O; текущий batch докоммичивается или откатывается целиком, блокировки освобождаются |
Входные параметры цикла
| Поле | Тип | Обязательность | Источник | Описание |
|---|
AsOf | DateTimeOffset | Да | системные часы | Момент цикла для сравнения с DueAt. |
BatchSize | int | Да | ReminderDispatchOptions.BatchSize (default 200) | Верхняя граница строк за цикл. |
TenantId | — | Нет | не применяется | Cross-tenant scan: платформенный system-worker без tenant-фильтра (ADR-0052 п.3) — напоминания всех агентств и консультантов ПИН. |
Алгоритм
- Контекст. Actor = system (cross-tenant разрешён явно);
correlationId = job run id; AsOf =
системные часы. Бизнес-идемпотентность обеспечивается предикатом статуса (выбираются только
Pending); отдельный idempotency-ключ не нужен.
- Проверка входа. Внешнего ввода нет; допустимость состояния встроена в предикат выборки:
Status = Pending && DueAt < AsOf (индекс ix_event_reminders_status_due_at); терминальные
Sent/Skipped не выбираются (INV-REM-2).
- Что читаем. Выборка под построчной блокировкой
SKIP LOCKED, порядок по DueAt asc (сначала
самые просроченные), лимит BatchSize; вместе со строкой читается родительское событие
(calendar-event.md) — статус и поля payload будущего события.
SKIP LOCKED пропускает строки, залоченные другим инстансом или in-flight операцией над событием
(перенос/отмена) — без ожидания.
- Бизнес-правила и переходы. Для каждой строки — по статусу родительского события:
- событие
Planned/InProgress → строка Pending → Sent, SentAt = AsOf — напоминание уходит;
- событие
Cancelled/Completed → строка Pending → Skipped без публикации (fail-safe: штатно
каскад события уже перевёл строки в Skipped — ветка закрывает гонку «отмена ↔ диспетчер»,
event-reminder.md, «Идемпотентность и отказоустойчивость»).
- Что меняется. Обновляются
Status/SentAt/UpdatedAt строк batch-а (уже заблокированных).
- Событие. По каждой
Sent-строке в транзакционный outbox пишется ReminderDueEvent
(ContractVersion = 1, topic pin.calendar.reminder-due.v1, key = EventId). Payload из строки и
родительского события: ReminderId, EventId, TenantId, OwnerUserId, ClientUserId?, Kind,
StartAt, TimeZone, OffsetMinutes, AppointmentId? — дом контракта
event-reminder.md (раздел «Доменные и интеграционные события»); PII в
payload нет. Потребитель — notifications (AppointmentReminder для событий записи,
CalendarReminder для остальных).
- Согласованность. Граница — на батч: переходы строк + записи outbox фиксируются атомарно в
одной записи; блокировки держатся до commit. Poison-строка исключается из батча, логируется и
остаётся на следующий цикл; остальной батч коммитится (
continue, не stop).
- Результат.
{ SentCount, SkippedCount, SkippedLockedCount, DurationMs } — для метрик/логов.
Инфраструктурная ошибка → откат записи, освобождение блокировок; ретрай — следующим циклом (те же
строки ещё Pending → выберутся снова, идемпотентно).
Граница транзакции
| Элемент | Правило |
|---|
| Единица согласованности | per batch; блокировки строк живут до commit |
| Сохраняемое состояние | переходы reminder-строк (Status/SentAt/UpdatedAt) + записи outbox (ReminderDueEvent) |
| External calls | Kafka-публикация — после commit, outbox-publisher-ом (at-least-once); внутри цикла внешних синхронных вызовов нет |
| Частичная ошибка | continue — poison-строка исключается из батча и логируется, остальной батч коммитится; инфраструктурный сбой — откат всего батча, ретрай следующим циклом |
Постусловия и цепочка следствий
Предусловия цикла. Существуют Pending-строки с наступившим DueAt (материализованы при
создании/переносе событий); notifications подписан на pin.calendar.reminder-due.v1.
Постусловия (после commit батча). Каждая обработанная строка терминальна (Sent с заполненным
SentAt либо Skipped); по каждой Sent — ровно одна запись ReminderDueEvent в outbox; строка больше
не выбирается предикатом.
Цепочка следствий (асинхронно). reminder-due после commit публикуется outbox-publisher-ом
(at-least-once); notifications доставляет напоминание владельцу события (и клиенту при
ClientUserId != null) по каналам получателя, дедуплицируя по ReminderId. Сквозная точность:
напоминание доставлено не позже DueAt + 60 с + DurationCycle + лаг notifications — целевой SLO ≤ 2 мин
от DueAt (README).
Что видит пользователь в промежутке. Между DueAt и доставкой (до ~2 мин) напоминания ещё нет — это
штатная точность polling-механизма; событие в календаре при этом видно всегда.
Идемпотентность и retry
| Сценарий | Правило |
|---|
| Job restart / повторный цикл | Checkpoint не нужен: выборка отбирает только Pending, закоммиченный переход делает строку терминальной и невыбираемой → повтор безопасен (idempotent by state, INV-REM-2) |
| Duplicate item (параллельные инстансы) | SKIP LOCKED — строка достаётся одному инстансу; ровно одно reminder-due на строку |
| Гонка worker ↔ отмена/перенос события | Строка под каскадом события залочена → worker пропускает её в этом цикле; если каскад закоммитил первым — строка уже Skipped/удалена и в выборку не попадает; если worker первым — напоминание уходит, событие отменяется следом (клиент получит и напоминание, и уведомление об отмене — допустимое окно, event-reminder.md) |
| Падение между переходом и доставкой | Публикация атомарна со сменой статуса (outbox); downstream — at-least-once, дедупликация notifications по ReminderId |
| Повторяемая ошибка (инфраструктура) | Откат батча, блокировки сняты; следующий цикл повторяет — кандидаты ещё Pending |
| Poison item | Исключается из батча, ERROR-лог с ReminderId/EventId; не блокирует батч; при систематическом повторе — алерт по errors_total |
Наблюдаемость
| Тип | Правило |
|---|
| Logs | cycle started (runId, AsOf); batch selected (count); reminder sent (ReminderId, EventId, OffsetMinutes, TenantId); reminder skipped (ReminderId, статус события); cycle completed (counts, DurationMs, SkippedLocked); item failed (ReminderId, exception). PII нет — только идентификаторы |
| Metrics | calendar_reminder_dispatch_cycle_duration_seconds (histogram); calendar_reminder_dispatch_items_total{result=sent|skipped}; calendar_reminder_dispatch_backlog (gauge — Pending с DueAt < now, ещё не обработанные); calendar_reminder_dispatch_lateness_seconds (histogram — SentAt − DueAt, контроль SLO ≤ 2 мин); calendar_reminder_dispatch_skip_locked_total; calendar_reminder_dispatch_errors_total; outbox lag |
| Traces | correlationId = job run id; tenantId (per item, из события); job run id — на span цикла и outbox-записи |
| Dashboard | Grafana calendar-overview (панель Background jobs) |
| Alerts | нет успешного цикла > 5 мин (liveness); Pending с DueAt старше 5 мин (README, «Alerts» — worker отстаёт/завис); errors_total rate > 0 устойчиво; outbox backlog |
Связанный Runbook
| Сценарий | Runbook |
|---|
| Worker завис / backlog растёт | ../runbooks/reminder-dispatch.md (будущий путь по конвенции) |
| Poison-строка не диспетчеризуется | ../runbooks/reminder-dispatch.md |
Лаг outbox reminder-due | ../runbooks/outbox-backlog.md (будущий путь) |
Тесты
| Тест | Ожидание |
|---|
| Empty batch | Нет Pending с DueAt < now → no-op success, событий/мутаций нет |
| Наступившее напоминание | Pending при DueAt < now, событие Planned → Sent, SentAt = now, ровно одно reminder-due в outbox |
| Отменённое событие | Pending-строка события Cancelled (каскад не успел/replay) → Skipped, публикации нет |
| Partial failure | Poison-строка исключена и залогирована, остальной батч закоммичен; poison — на следующий цикл |
| Duplicate / параллельные инстансы | SKIP LOCKED — каждая строка обработана один раз, ровно одно событие на строку |
| Гонка с отменой события | Отмена закоммичена между чтением и записью worker-а → конфликт разрешается перечиткой: терминальная строка не отправляется |
| Cancellation | остановка во время цикла → батч докоммичен или откачен целиком, блокировки сняты, graceful stop |
| SLO точности | Строка с DueAt = t уходит не позже t + 60 с + DurationCycle; lateness_seconds в пределах SLO ≤ 2 мин |
Обратные ссылки
Автоссылки: где используется этот документ.
Связанные документы