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

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 при захвате строк).

ПолеЗначение
OwnerPIN 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 contextgreenfield (новый worker; заявлен в README «Проекты»/«Background jobs»; новый produced-топик — расширяет реестр событий README)

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

ПолеЗначение
Сервисprofile
Тип jobrecurring 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 sizeBatchSize документов-кандидатов за проход (default 500); выборка повторяется, пока батч полон — backlog разгребается за несколько итераций (сортировка по ExpiresAt asc)
Max durationMaxRunDuration (default 10 мин) — предохранитель; при превышении прогон завершается штатно, остаток берёт следующий tick
Cancellationсигнал остановки таймера/хоста учитывается между операциями; текущая транзакция владельца докоммичивается или откатывается целиком (graceful stop)

Входная Модель RemindExpiringDocumentsInput

ПолеТипОбязательностьИсточникОписание
AsOfDateTimeOffsetДатекущее время UTCМомент цикла; дата AsOf — база для DaysLeft.
ThresholdsDaysIReadOnlyList<int>Даконфиг (default [30, 7, 1, 0])Пороги «за сколько дней»; 0 = день истечения и позже.
BatchSizeintДаконфиг (default 500, > 0)Верхняя граница документов-кандидатов за проход.
TenantIdTenantIdНетне применяется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 при этом не изменяется.

ПолеТипОбязательностьОграниченияОписание
IdGuidДаPKИдентификатор маркера.
DocumentIdGuidДачасть UK; логическая ссылка на person_documents.id (без FK — RO)Документ, о котором напомнили.
OwnerUserIdGuidДаindex ix_document_expiry_reminders_ownerВладелец (для группировки/аудита).
TypePersonDocumentTypeДаvarchar(32) (enum как текст)Тип документа (без PII).
ThresholdDaysintДаUK; ∈ ThresholdsDaysСработавший порог (30/7/1/0).
ExpiresAtDateOnlyДаindex ix_document_expiry_reminders_expires_atКопия срока — для pruning.
RemindedAtDateTimeOffsetДаКогда напоминание отправлено.

Индексы: 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 }.

  1. Контекст и доступ. Актор — system (cross-owner скан разрешён явно, tenant/record-level фильтры не применяются); correlationId = jobRunId; AsOf — текущее время UTC. Источник запуска — расписание, не топик; идемпотентность обеспечивается леджером маркеров (шаг 5).
  2. Проверка входа. Внешнего ввода нет; конфиг проверен на старте (ThresholdsDays непуст и отсортирован по убыванию, BatchSize > 0). Допустимость состояния документов встроена в фильтр выборки (шаг 3).
  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 (строки, занятые параллельным инстансом, пропускаются).
  4. Бизнес-правила и маппинг. Для каждого кандидата вычислить пересечённые пороги crossed; убрать пороги, для которых маркер уже есть; если что-то осталось — t* = min(оставшиеся) (самый срочный ещё не отправленный порог). Все пройденные пороги помечаются отправленными (шаг 5), а уведомление шлётся только за t* — так свежедобавленный почти-просроченный документ не спамит инвестора за все окна сразу. Результат группируется по OwnerUserId; на владельца — список ExpiringDocumentItem (DocumentId, Type, NumberMask, ExpiresAt, ThresholdDays = t*, DaysLeft), без PII.
  5. Что меняется (claim + mark). На каждого владельца — своя транзакция. Для каждого (DocumentId, t) из crossed вставляется маркер по правилу «при конфликте (document_id, threshold_days) — ничего»; вставленные строки = «выигранные» этим инстансом (insert-wins). Владельцы/документы без вставок (маркер уже был — параллельный инстанс или повторный цикл) из события исключаются. Устаревшие маркеры (см. леджер) удаляются отдельной операцией.
  6. События. На владельца с ≥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).
  7. Согласованность. Граница — per-owner: маркеры владельца и outbox-запись фиксируются в одном согласованном изменении; insert-wins на уникальном индексе разрешает гонку инстансов. Prune устаревших маркеров — отдельной транзакцией в конце цикла.
  8. Результат. Метрики прогона (RemindersSentCount, OwnersNotifiedCount, SkippedAlreadyMarkedCount, PrunedCount, DurationMs) — для логов/метрик, не HTTP-ответ. Пустой батч — no-op success. Инфраструктурная ошибка → откат транзакции владельца; ретрай — следующим циклом (документ ещё в окне, маркер не записан → выберется снова, идемпотентно).

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

ЭлементПравило
Unit of workper owner — маркеры владельца + outbox-запись в одном согласованном изменении; prune — отдельная транзакция
Сохраняемое состояниеdocument_expiry_reminders (новые маркеры; prune-удаление) + outbox-запись (DocumentExpiryReminderKafkaEvent). person_documentsне пишется
External callsKafka — после коммита, из 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

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

ТипПравило
Logscycle 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)
Metricsprofile_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
TracescorrelationId = jobRunId на span цикла и outbox-записи; атрибуты ownerUserId (per owner), batchSize
DashboardGrafana 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 / без PIIperson_documents не изменяется; PayloadCipher/крипто-ключи не читаются; в логах/событии нет значений документа
Бессрочный документExpiresAt = null (паспорт РФ, ИНН) — вне выборки, напоминаний нет
Pruneмаркеры с ExpiresAt < AsOf.Date − 90д удаляются; активные окна не затронуты
Добавлен порог в конфигновый порог (напр. 14) срабатывает для документов, ещё не помеченных за него; за уже пройденные и помеченные пороги повторных напоминаний нет
Удалён порог из конфигастарые маркеры удалённого порога остаются (dedupe не нарушается), новых напоминаний за него нет
Продление документаExpiresAt сдвинут в будущее до следующего цикла → документ вне окна, новых напоминаний нет; старые маркеры не мешают (dedupe по (doc, threshold))
Cancellationостановка во время цикла → транзакция владельца докоммичена/откачена целиком, graceful stop

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

  • Дом полей/индекса/инвариантов: ../domain/person-document.md (Status/ExpiresAt/NumberMask/Type, ix_person_documents_expires_at), персона — ../domain/person.md (OwnerUserId, record-level).
  • Публикуемый контракт (будущий): ../events/document-expiry-reminder.md; обзор/эксплуатация и реестр событий — ../README.md; потребитель — notifications (по будущему ТЗ).
  • Решения: ADR-0056 simplicity-first (polling без Redis, дедуп в PostgreSQL, FOR UPDATE SKIP LOCKED), ADR-0052 tenancy (инвестор вне tenant, system-скан).