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

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 атомарно в одной записи.

ПолеЗначение
OwnerPIN 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
Тип jobRecurring (polling-worker)
Triggerцикл 60 с (без внешнего триггера/топика)
Критичностьtier-2 (пропуск напоминания не ломает данные; страдает UX встреч и дедлайнов)

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

АспектПравило
Scheduleкаждые 60 с (UTC-часы); следующий тик не стартует, пока идёт предыдущий цикл (без наложений)
Distributed lockнет глобального lock (ADR-0056 — без Redis); параллельные инстансы координируются построчной блокировкой SKIP LOCKED — каждая строка достаётся ровно одному инстансу
Batch size200 строк/цикл (ReminderDispatchOptions.BatchSize); backlog добирается следующими циклами (order by DueAt asc)
Max durationцель < 30 с/цикл; при превышении — тик пропускается, добор в следующем цикле
Cancellationостановка хоста проходит в каждый I/O; текущий batch докоммичивается или откатывается целиком, блокировки освобождаются

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

ПолеТипОбязательностьИсточникОписание
AsOfDateTimeOffsetДасистемные часыМомент цикла для сравнения с DueAt.
BatchSizeintДаReminderDispatchOptions.BatchSize (default 200)Верхняя граница строк за цикл.
TenantIdНетне применяетсяCross-tenant scan: платформенный system-worker без tenant-фильтра (ADR-0052 п.3) — напоминания всех агентств и консультантов ПИН.

Алгоритм

  1. Контекст. Actor = system (cross-tenant разрешён явно); correlationId = job run id; AsOf = системные часы. Бизнес-идемпотентность обеспечивается предикатом статуса (выбираются только Pending); отдельный idempotency-ключ не нужен.
  2. Проверка входа. Внешнего ввода нет; допустимость состояния встроена в предикат выборки: Status = Pending && DueAt < AsOf (индекс ix_event_reminders_status_due_at); терминальные Sent/Skipped не выбираются (INV-REM-2).
  3. Что читаем. Выборка под построчной блокировкой SKIP LOCKED, порядок по DueAt asc (сначала самые просроченные), лимит BatchSize; вместе со строкой читается родительское событие (calendar-event.md) — статус и поля payload будущего события. SKIP LOCKED пропускает строки, залоченные другим инстансом или in-flight операцией над событием (перенос/отмена) — без ожидания.
  4. Бизнес-правила и переходы. Для каждой строки — по статусу родительского события:
    • событие Planned/InProgress → строка Pending → Sent, SentAt = AsOf — напоминание уходит;
    • событие Cancelled/Completed → строка Pending → Skipped без публикации (fail-safe: штатно каскад события уже перевёл строки в Skipped — ветка закрывает гонку «отмена ↔ диспетчер», event-reminder.md, «Идемпотентность и отказоустойчивость»).
  5. Что меняется. Обновляются Status/SentAt/UpdatedAt строк batch-а (уже заблокированных).
  6. Событие. По каждой 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 для остальных).
  7. Согласованность. Граница — на батч: переходы строк + записи outbox фиксируются атомарно в одной записи; блокировки держатся до commit. Poison-строка исключается из батча, логируется и остаётся на следующий цикл; остальной батч коммитится (continue, не stop).
  8. Результат. { SentCount, SkippedCount, SkippedLockedCount, DurationMs } — для метрик/логов. Инфраструктурная ошибка → откат записи, освобождение блокировок; ретрай — следующим циклом (те же строки ещё Pending → выберутся снова, идемпотентно).

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

ЭлементПравило
Единица согласованностиper batch; блокировки строк живут до commit
Сохраняемое состояниепереходы reminder-строк (Status/SentAt/UpdatedAt) + записи outbox (ReminderDueEvent)
External callsKafka-публикация — после 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

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

ТипПравило
Logscycle 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 нет — только идентификаторы
Metricscalendar_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
TracescorrelationId = job run id; tenantId (per item, из события); job run id — на span цикла и outbox-записи
DashboardGrafana 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, событие PlannedSent, SentAt = now, ровно одно reminder-due в outbox
Отменённое событиеPending-строка события Cancelled (каскад не успел/replay) → Skipped, публикации нет
Partial failurePoison-строка исключена и залогирована, остальной батч закоммичен; poison — на следующий цикл
Duplicate / параллельные инстансыSKIP LOCKED — каждая строка обработана один раз, ровно одно событие на строку
Гонка с отменой событияОтмена закоммичена между чтением и записью worker-а → конфликт разрешается перечиткой: терминальная строка не отправляется
Cancellationостановка во время цикла → батч докоммичен или откачен целиком, блокировки сняты, graceful stop
SLO точностиСтрока с DueAt = t уходит не позже t + 60 с + DurationCycle; lateness_seconds в пределах SLO ≤ 2 мин

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

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

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