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

Entity: EventReminderEntity

Дом полей сущности «Напоминание о событии» контекста calendar — материализованная строка «напомнить за N минут до начала события», порождаемая из офсетов ReminderOffsetsMinutes календарного события (calendar-event.md). Материализованные строки + polling-диспетчер — решение ADR-0064 п.5 (без Redis/TTL, ADR-0056).

Назначение

EventReminderEntity фиксирует конкретный момент DueAt, в который владельцу события (и клиенту, если он есть) должно уйти напоминание. Наступившие строки выбирает фоновый диспетчер (../background/reminder-dispatch.md, цикл 60 с, построчная блокировка) и публикует pin.calendar.reminder-due.v1; доставку по каналам выполняет контекст notifications. Собственных API-операций у напоминаний нет — они управляются только жизненным циклом родительского события.

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

ПолеЗначение
Контекстcalendar
KindEntity (owned — часть агрегата CalendarEventEntity)
Source of truthcalendar
Таблицаcalendar.event_reminders
Tenant isolationчерез родительское событие (CalendarEventId); диспетчер работает в system-scope (ADR-0052)

Поля

ПолеТипОбязательностьОграничения (PK/FK/index/constraint)ИсточникОписание
IdGuidДаPKappИдентификатор строки напоминания.
CalendarEventIdGuidДаFK fk_event_reminders_event_id → calendar.calendar_events(id), cascade; unique partial ux_event_reminders_pending (CalendarEventId, OffsetMinutes) where status = 'Pending'appРодительское событие.
OffsetMinutesintДа> 0из ReminderOffsetsMinutes событияОфсет «за N минут до StartAt».
DueAtDateTimeOffsetДаindex ix_event_reminders_status_due_at (Status, DueAt)app: StartAt − OffsetMinutesМомент срабатывания, UTC.
StatusReminderStatusДаenum (хранится как text), varchar(32)app / workerСм. enum и переходы.
SentAtDateTimeOffset?НетworkerФактический момент отправки (→ Sent).
CreatedAtDateTimeOffsetДаappМомент материализации строки.
UpdatedAtDateTimeOffsetДаapp / workerПоследнее изменение.

Enum ReminderStatus

ЗначениеОписание
Pending = 1Ожидает срабатывания (DueAt в будущем на момент материализации).
Sent = 2Диспетчер обработал строку и опубликовал pin.calendar.reminder-due.v1. Терминальный.
Skipped = 3Напоминание не актуально: событие отменено/завершено до DueAt, либо DueAt уже в прошлом на момент материализации. Терминальный.

Правила материализации

  1. Создание события. Для каждого офсета из ReminderOffsetsMinutes создаётся ровно одна строка: DueAt = StartAt − OffsetMinutes. Если DueAt ≤ now — строка создаётся сразу в Skipped (прошедшие напоминания не отправляются задним числом). Пустой массив офсетов — строк нет.
  2. Перенос события (reschedule) и изменение офсетов. Все Pending-строки события удаляются, новый набор материализуется от нового StartAt по актуальным офсетам (правило 1 применяется заново, включая Skipped для уже прошедших DueAt). Sent/Skipped-строки не трогаются — это исторический факт.
  3. Отмена или завершение события (Status → Cancelled | Completed). Все Pending-строки события переводятся в Skipped в том же согласованном изменении.
  4. Диспетчеризация. Worker ../background/reminder-dispatch.md циклом 60 с выбирает строки Status = Pending AND DueAt < now под построчной блокировкой, переводит каждую в Sent (SentAt = now) и атомарно публикует pin.calendar.reminder-due.v1. Опоздание доставки до ~1 цикла — штатная точность механизма.

Инварианты и переходы состояний

ИнвариантУсловиеКогда проверяетсяОшибка
INV-REM-1У события не более одной Pending-строки на офсет (unique partial ux_event_reminders_pending); для каждой Pending-строки DueAt = StartAt − OffsetMinutes согласован с актуальным StartAt события — согласованность обеспечивается пересозданием при переносе (правило 2)материализация, перенос события— (структурная гарантия)
INV-REM-2Терминальные Sent/Skipped иммутабельны. Sent выставляет только диспетчер, однократно — ровно одно reminder-due на строку; Skipped выставляется при отмене/завершении события и при материализации с DueAt в прошломдиспетчеризация, каскады события— (структурная гарантия)

Идемпотентность и отказоустойчивость

  • Идемпотентность диспетчера. Переход Pending → Sent выполняется под построчной блокировкой в одном согласованном изменении с публикацией события; повторный проход worker-а видит Sent и не делает ничего. Два экземпляра worker-а не обработают одну строку дважды (построчная блокировка с пропуском занятых строк).
  • Падение между переходом и доставкой. Публикация reminder-due атомарна со сменой статуса (transactional outbox); downstream-доставка — at-least-once, дедупликация на стороне notifications по ReminderId.
  • Гонка «диспетчер ↔ отмена события». Арбитраж — построчная блокировка: если отмена успела первой, диспетчер видит Skipped и пропускает; если первым успел диспетчер — напоминание уходит, событие отменяется следом (клиент получит и напоминание, и уведомление об отмене — допустимое окно).

Доменные и интеграционные события

СобытиеТипКогдаSnapshot/поля
ReminderDueEventинтеграционное, topic pin.calendar.reminder-due.v1, ContractVersion = 1, key = EventIdPending → Sent (диспетчер)ReminderId, EventId, TenantId, OwnerUserId, ClientUserId?, Kind, StartAt, TimeZone, OffsetMinutes, AppointmentId?; консюмер: notifications (AppointmentReminder для событий записи, CalendarReminder для остальных); контракт — ../events/reminder-due.md; PII в payload нет

Индексы, хранение, безопасность

АспектЗначение
ИндексыPK; ix_event_reminders_status_due_at (Status, DueAt) — выборка диспетчера Pending + DueAt < now; unique partial ux_event_reminders_pending (CalendarEventId, OffsetMinutes) where status = 'Pending'
Партиционирование / retentionnone (ADR-0056); терминальные строки старше 180 дней допустимо удалять фоновой уборкой вместе с архивом событий
Concurrencyпострочная блокировка диспетчера; вне worker-а строки меняются только каскадами родительского события
Permissions / PIIсобственных API-операций нет; строки видны опосредованно (офсеты — в модели события). PII не хранится
AuditCreatedAt/UpdatedAt/SentAt; факт отправки — событие reminder-due

Acceptance-критерии

  • Материализация по default. Дано: событие создаётся за 3 дня до StartAt с офсетами {1440, 60}. Тогда созданы две Pending-строки с DueAt = StartAt − 24 ч и StartAt − 1 ч.
  • Прошедший офсет. Дано: событие создаётся за 30 минут до StartAt с офсетами {1440, 60}. Тогда обе строки созданы сразу в Skipped, ни одно reminder-due не публикуется.
  • Срабатывание. Дано: Pending-строка с DueAt < now. Когда проходит диспетчер. Тогда строка Sent (SentAt заполнен), опубликовано ровно одно pin.calendar.reminder-due.v1.
  • Пересоздание при переносе. Дано: событие с двумя Pending-строками переносится на неделю позже. Тогда старые Pending-строки удалены, созданы новые с DueAt от нового StartAt; ранее отправленные Sent-строки не изменились.
  • Отмена события. Дано: событие с Pending-строками отменяется. Тогда все PendingSkipped, напоминания не отправляются.
  • Идемпотентность. Дано: строка уже Sent. Когда диспетчер проходит повторно. Тогда статус и число опубликованных событий не меняются.

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

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

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

  • calendar-event.md — родительский агрегат, офсеты ReminderOffsetsMinutes.
  • Диспетчер: ../background/reminder-dispatch.md; контракт события: ../events/reminder-due.md.
  • Паттерн polling + построчная блокировка: booking-expiration.md, ADR-0056.