Background Job: DocumentExpiryReminderJob
Назначение
DocumentExpiryReminderJob — фоновый polling-worker контекста profile (Pin.Profile.Background), который
находит активные документы персон с приближающимся или наступившим сроком действия (ExpiresAt) и
инициирует напоминание инвестору обновить документ. Сам worker уведомления не шлёт: он публикует
интеграционное событие DocumentExpiryReminderKafkaEvent (будущий документ) в outbox,
а рендер и доставку push/e-mail делает сервис notifications (граница по README: «уведомления
— notifications по событиям»).
Опирается на partial-индекс ix_person_documents_expires_at where expires_at is not null документа
PersonDocumentEntity (там же — дом полей Type/NumberMask/ExpiresAt).
Документы worker не мутирует (read-only-скан) и не расшифровывает PayloadCipher — читает только
открытые проекции-колонки, поэтому крипто-ключи ему не нужны и он работает даже при недоступном Vault.
Дедуп «одно напоминание на порог» ведётся в отдельном леджере (см. ниже), person_documents не трогается.
Механизм — polling без Redis/TTL (ADR-0056); координация инстансов и идемпотентность — в PostgreSQL
(insert-wins по уникальному индексу леджера + FOR UPDATE SKIP LOCKED при захвате строк).
| Поле | Значение |
|---|
| Owner | PIN Platform Core (profile squad) |
| Статус документа | draft |
| Область действия | worker document-expiry-reminder, таблицы profile.person_documents (RO), profile.document_expiry_reminders, топик pin.profile.document-expiry-reminders.v1 |
| Assumptions / ограничения | пороги [30, 7, 1, 0] дней (конфиг); дни считаются по UTC-дате ExpiresAt (без учёта таймзоны инвестора — приемлемо для напоминаний); бессрочные документы (паспорт РФ, ИНН) с ExpiresAt = null вне выборки; целевая нагрузка ADR-0056 |
| Зависимости | PostgreSQL (profile, outbox); Kafka (outbox-publisher); потребитель события — notifications |
| Change context | greenfield (новый worker; заявлен в README «Проекты»/«Background jobs»; новый produced-топик — расширяет реестр событий README) |
Классификация
| Поле | Значение |
|---|
| Сервис | profile |
| Тип job | recurring polling-worker |
| Trigger | таймер PollingInterval (default 24 ч, запуск в низкий по нагрузке час UTC); без внешнего топика |
| Актор | system (внутренний, публичного входа нет) |
| Критичность | medium (вне денежного пути; деградация задерживает напоминания, данными не рискует) |
Расписание И Locking
| Аспект | Правило |
|---|
| Schedule | каждые PollingInterval (default 24 ч, UTC); следующий тик не стартует, пока идёт предыдущий цикл |
| Distributed lock | нет глобального lock (ADR-0056 — без Redis). Документы читаются read-only без блокировки (worker их не мутирует и намеренно не мешает self-правкам инвестора). Арбитр «кто шлёт напоминание» — уникальный индекс леджера ux_document_expiry_reminders_dedupe (insert-wins — read-only-эквивалент SKIP LOCKED для новых строк); существующие маркеры читаются с FOR UPDATE SKIP LOCKED (без ожидания на чужой блокировке) |
| Batch size | BatchSize документов-кандидатов за проход (default 500); выборка повторяется, пока батч полон — backlog разгребается за несколько итераций (сортировка по ExpiresAt asc) |
| Max duration | MaxRunDuration (default 10 мин) — предохранитель; при превышении прогон завершается штатно, остаток берёт следующий tick |
| Cancellation | сигнал остановки таймера/хоста учитывается между операциями; текущая транзакция владельца докоммичивается или откатывается целиком (graceful stop) |
| Поле | Тип | Обязательность | Источник | Описание |
|---|
AsOf | DateTimeOffset | Да | текущее время UTC | Момент цикла; дата AsOf — база для DaysLeft. |
ThresholdsDays | IReadOnlyList<int> | Да | конфиг (default [30, 7, 1, 0]) | Пороги «за сколько дней»; 0 = день истечения и позже. |
BatchSize | int | Да | конфиг (default 500, > 0) | Верхняя граница документов-кандидатов за проход. |
TenantId | TenantId | Нет | не применяется | Cross-owner scan: платформенный system-worker без tenant/record-level фильтра (ADR-0052 п.2–3: данные инвестора вне tenant), обрабатывает документы всех инвесторов. |
Actor — system; публичного входа у job нет. MaxThreshold = max(ThresholdsDays) задаёт окно скана.
Леджер дедупа profile.document_expiry_reminders
Job-local инфраструктурный леджер (не доменный агрегат, событий не поднимает; дом полей — здесь). Одна
строка = «за (документ, порог) напоминание уже отправлено». PersonDocumentEntity при этом не изменяется.
| Поле | Тип | Обязательность | Ограничения | Описание |
|---|
Id | Guid | Да | PK | Идентификатор маркера. |
DocumentId | Guid | Да | часть UK; логическая ссылка на person_documents.id (без FK — RO) | Документ, о котором напомнили. |
OwnerUserId | Guid | Да | index ix_document_expiry_reminders_owner | Владелец (для группировки/аудита). |
Type | PersonDocumentType | Да | varchar(32) (enum как текст) | Тип документа (без PII). |
ThresholdDays | int | Да | UK; ∈ ThresholdsDays | Сработавший порог (30/7/1/0). |
ExpiresAt | DateOnly | Да | index ix_document_expiry_reminders_expires_at | Копия срока — для pruning. |
RemindedAt | DateTimeOffset | Да | — | Когда напоминание отправлено. |
Индексы: PK; ux_document_expiry_reminders_dedupe unique (document_id, threshold_days) — идемпотентность;
ix_document_expiry_reminders_owner; ix_document_expiry_reminders_expires_at — pruning. Retention: маркеры
с ExpiresAt < AsOf.Date − 90 дней удаляются в конце цикла — леджер остаётся ограниченным; архив/удаление
документа делает его маркеры невыбираемыми (документ уходит из скана).
Ключ дедупа — (document_id, threshold_days) без ExpiresAt (осознанно): один документ не напоминается
дважды за один порог. При продлении документа (инвестор ставит новый ExpiresAt в будущее) документ
выходит из окна скана; повторное напоминание за тот же порог возможно только после prune старого маркера
(его копия ExpiresAt — прежний срок — уходит за AsOf.Date − 90 дней). Для реальных сроков (годы между
выдачами) это не ограничивает; при искусственном сжатии окна (продление менее чем на 90 дней от старого
срока) первое пересечение того же порога после продления будет подавлено до prune — приемлемая деградация
для напоминаний (не денежный путь).
Алгоритм
Каждый tick запускает один цикл. dueDateMax = AsOf.Date + MaxThreshold;
DaysLeft(d) = d.ExpiresAt.DayNumber − AsOf.Date.DayNumber; crossed(d) = { t ∈ ThresholdsDays : DaysLeft(d) ≤ t }.
- Контекст и доступ. Актор — system (cross-owner скан разрешён явно, tenant/record-level фильтры не
применяются);
correlationId = jobRunId; AsOf — текущее время UTC. Источник запуска — расписание, не
топик; идемпотентность обеспечивается леджером маркеров (шаг 5).
- Проверка входа. Внешнего ввода нет; конфиг проверен на старте (
ThresholdsDays непуст и отсортирован
по убыванию, BatchSize > 0). Допустимость состояния документов встроена в фильтр выборки (шаг 3).
- Загрузка кандидатов (read-only, без блокировки). Выбрать активные неудалённые документы с заданным
сроком
ExpiresAt <= dueDateMax (по partial-индексу ix_person_documents_expires_at, сортировка по
ExpiresAt asc, порция BatchSize). Читаются только открытые колонки (Id, OwnerUserId,
PersonId, Type, NumberMask, ExpiresAt); зашифрованный payload и крипто-ключи не затрагиваются
(PII не расшифровывается). Существующие маркеры для этих документов читаются с FOR UPDATE SKIP LOCKED
(строки, занятые параллельным инстансом, пропускаются).
- Бизнес-правила и маппинг. Для каждого кандидата вычислить пересечённые пороги
crossed; убрать
пороги, для которых маркер уже есть; если что-то осталось — t* = min(оставшиеся) (самый срочный ещё не
отправленный порог). Все пройденные пороги помечаются отправленными (шаг 5), а уведомление шлётся только
за t* — так свежедобавленный почти-просроченный документ не спамит инвестора за все окна сразу.
Результат группируется по OwnerUserId; на владельца — список ExpiringDocumentItem (DocumentId,
Type, NumberMask, ExpiresAt, ThresholdDays = t*, DaysLeft), без PII.
- Что меняется (claim + mark). На каждого владельца — своя транзакция. Для каждого
(DocumentId, t)
из crossed вставляется маркер по правилу «при конфликте (document_id, threshold_days) — ничего»;
вставленные строки = «выигранные» этим инстансом (insert-wins). Владельцы/документы без вставок (маркер
уже был — параллельный инстанс или повторный цикл) из события исключаются. Устаревшие маркеры (см.
леджер) удаляются отдельной операцией.
- События. На владельца с ≥1 выигранным
t* — одно интеграционное DocumentExpiryReminderKafkaEvent
(ContractVersion = 1, топик pin.profile.document-expiry-reminders.v1, key = OwnerUserId,
AggregateType = "Profile") в транзакционный outbox. Payload: OwnerUserId, RemindedAt = AsOf,
Documents: List<ExpiringDocumentItem> — дом контракта
document-expiry-reminder.md (будущий документ). Доменных событий
нет (леджер — не агрегат). PII не публикуется (только Type/NumberMask/ExpiresAt).
- Согласованность. Граница — per-owner: маркеры владельца и outbox-запись фиксируются в одном
согласованном изменении; insert-wins на уникальном индексе разрешает гонку инстансов. Prune устаревших
маркеров — отдельной транзакцией в конце цикла.
- Результат. Метрики прогона (
RemindersSentCount, OwnersNotifiedCount, SkippedAlreadyMarkedCount,
PrunedCount, DurationMs) — для логов/метрик, не HTTP-ответ. Пустой батч — no-op success.
Инфраструктурная ошибка → откат транзакции владельца; ретрай — следующим циклом (документ ещё в окне,
маркер не записан → выберется снова, идемпотентно).
Граница Транзакции
| Элемент | Правило |
|---|
| Unit of work | per owner — маркеры владельца + outbox-запись в одном согласованном изменении; prune — отдельная транзакция |
| Сохраняемое состояние | document_expiry_reminders (новые маркеры; prune-удаление) + outbox-запись (DocumentExpiryReminderKafkaEvent). person_documents — не пишется |
| External calls | Kafka — после коммита, из outbox (at-least-once); внутри цикла синхронных внешних вызовов и вызовов storage нет |
| Частичная ошибка | continue — сбой на одном владельце логируется и не откатывает уже закоммиченных; его документы (без маркера) берёт следующий цикл |
Идемпотентность И Retry
| Сценарий | Правило |
|---|
| Job restart / повторный цикл | Checkpoint не нужен: наличие маркера (document_id, threshold_days) делает порог невыбираемым; рестарт заново фильтрует кандидатов и пропускает уже отмеченные |
| Duplicate item (N инстансов) | вставка «при конфликте — ничего» по ux_document_expiry_reminders_dedupe — маркер вставляет ровно один инстанс, второй получает 0 строк и события не шлёт (insert-wins; FOR UPDATE SKIP LOCKED на чтении маркеров убирает ожидание) |
| Гонка worker ↔ инвестор (edit/archive документа) | Документы читаются RO без блокировки — self-правки не блокируются; архив/смена ExpiresAt между read и claim лишь меняет будущую выборку, отправленный маркер остаётся корректным за свой порог |
| Повторяемая ошибка (инфраструктура) | Откат транзакции владельца; маркер не записан → следующий цикл (24 ч, либо ручной ретриггер) повторит те же кандидаты |
| Дубликат события | outbox — at-least-once; потребитель notifications дедуплицирует по AggregateId + Documents[].DocumentId/ThresholdDays |
Наблюдаемость
| Тип | Правило |
|---|
| Logs | cycle started (runId, AsOf, thresholds); batch selected (count); reminder queued (OwnerUserId, DocumentId, Type, ThresholdDays, DaysLeft — без NumberMask и значений PII в логах); cycle completed (counts, PrunedCount, DurationMs); owner failed (OwnerUserId, exception) |
| Metrics | profile_document_expiry_reminders_total{threshold_days} (counter — отправленные напоминания); profile_document_expiry_reminder_cycle_duration_seconds (histogram); profile_document_expiry_reminder_backlog (gauge — Active-документы в окне без маркера на текущий t*); profile_document_expiry_reminder_errors_total; outbox lag |
| Traces | correlationId = jobRunId на span цикла и outbox-записи; атрибуты ownerUserId (per owner), batchSize |
| Dashboard | Grafana profile-overview (панель Background jobs) |
| Alerts | нет успешного цикла > 26 ч (liveness при суточном расписании); backlog > 0 устойчиво > 2 циклов (worker отстаёт/завис); errors_total rate > 0 устойчиво; outbox backlog > 1000 (README) |
SLO Цикла
| Показатель | Цель | Обоснование |
|---|
| Лаг напоминания | документ, пересёкший порог t в дату ExpiresAt − t, попадает в событие ≤ PollingInterval (≤ 24 ч) | суточный polling; проверяется backlog gauge |
| Длительность цикла | p95 < MaxRunDuration (10 мин), заметно меньше интервала | скан по partial-индексу + проекция без decrypt |
| Идемпотентность | 0 повторных напоминаний за один (документ, порог) при рестартах и N инстансах | ux_..._dedupe + insert-wins |
| PII-безопасность | 0 значений документов в логах/событиях (только Type/NumberMask/ExpiresAt); PayloadCipher не читается | проекция открытых колонок; аудит логов |
Связанный Runbook
| Сценарий | Runbook |
|---|
| Backlog растёт (worker не успевает/стоит) | ../runbooks/document-expiry-reminder.md (будущий путь): проверить worker/pod, PollingInterval, лаг outbox |
Всплеск errors_total | тот же runbook: доступность PostgreSQL/схемы profile, откаты транзакций; маркеры не записаны — напомнит следующий цикл |
| Лаг outbox топика | ../runbooks/outbox-backlog.md (будущий путь) |
Тесты
| Тест | Ожидание |
|---|
| Empty batch | нет Active-документов с ExpiresAt в окне — no-op success, маркеров/событий нет |
| Пересечение порога | ExpiresAt − AsOf.Date = 7 при пороге 7 и без маркера → маркер (doc,7), одно событие владельцу, ThresholdDays=7 |
| Наступивший срок | ExpiresAt ≤ AsOf.Date → порог 0, DaysLeft ≤ 0, напоминание об истечении один раз |
| Свежий почти-просроченный | DaysLeft=5: помечены пороги 30 и 7, отправлено только за t*=7 (без спама) |
| Идемпотентность/рестарт | повторный прогон по тем же данным вставляет 0 маркеров, событий не шлёт |
| Параллельные инстансы | два worker-а на один (документ, порог) → ровно один маркер и одно событие (DO NOTHING) |
| Группировка | у владельца 3 документа в окне → одно событие со списком из 3 ExpiringDocumentItem |
| Read-only / без PII | person_documents не изменяется; PayloadCipher/крипто-ключи не читаются; в логах/событии нет значений документа |
| Бессрочный документ | ExpiresAt = null (паспорт РФ, ИНН) — вне выборки, напоминаний нет |
| Prune | маркеры с ExpiresAt < AsOf.Date − 90д удаляются; активные окна не затронуты |
| Добавлен порог в конфиг | новый порог (напр. 14) срабатывает для документов, ещё не помеченных за него; за уже пройденные и помеченные пороги повторных напоминаний нет |
| Удалён порог из конфига | старые маркеры удалённого порога остаются (dedupe не нарушается), новых напоминаний за него нет |
| Продление документа | ExpiresAt сдвинут в будущее до следующего цикла → документ вне окна, новых напоминаний нет; старые маркеры не мешают (dedupe по (doc, threshold)) |
| Cancellation | остановка во время цикла → транзакция владельца докоммичена/откачена целиком, graceful stop |
Связанные Документы