Background Job: SessionRetentionJob
Назначение
SessionRetentionJob — фоновый maintenance-воркер модуля аутентификации identity, который физически
удаляет отработавшие строки двух сущностей user-session.md по регламенту ретенции
(раздел «Партиционирование / retention»):
SignInConfirmationRequest (далее SICR) — короткоживущие запросы кодов: удалить записи старше
SicrRetention (30 дней) по CreatedAt (индекс ix_sicr_created_at), независимо от Status
(Confirmed/Expired/Exhausted/Superseded/Pending — все инертны спустя 30 дней; TTL самого кода 5 мин).
RefreshSession — удалить терминальные сессии (Status ∈ {Rotated, Revoked, Expired}) старше
RefreshTerminalRetention (90 дней) по UpdatedAt (момент терминализации, дальше не меняется). Живые
(Active) не трогаются вне зависимости от возраста.
Retention — про очистку хранилища и PII: PhoneNumber/UserAgent/IpAddress в этих строках покидают
БД не позже порога ретенции + один прогон. Аудит-историю сессий это не рушит: переходы уже отражены статусами
до удаления, витрина user-activity.sessions.* живёт своей проекцией.
Отличать от соседних воркеров (README «jobs: expire-sessions, retention, access fan-out»):
- expire-sessions (expire-sessions.md) — смена статуса
Active → Expired
(материализация TTL), запись остаётся в БД. Здесь — обратный конец жизненного цикла: удаление уже
терминальной строки. Retention никогда не меняет статус.
- access fan-out / expire-role-assignments (expire-role-assignments.md) —
работают с
RoleAssignment/правами, не с сессиями.
| Поле | Значение |
|---|
| Статус документа | draft |
| Owning team | PIN Platform Core |
| Область действия | контекст identity, таблицы identity.sign_in_confirmation_requests / identity.refresh_sessions |
| Assumptions/ограничения | Пороги — регламент хранения (SICR 30 дней, refresh-терминалы 90 дней), не security-контроль; при объёмах ADR-0056 (1–10k активных) партиционирования нет — удаление батчами. refresh_sessions не имеет выделенного retention-индекса (в отличие от ix_sicr_created_at): скан терминалов идёт по Status-guard + UpdatedAt, порционно по PK; при росте объёма — партиционирование (отложено). Self-FK ParentSessionId (Restrict) требует удаления цепочки ротаций «от листа к корню» (см. Алгоритм шаг 4) |
| Зависимости | PostgreSQL (schema identity). Шина событий не используется (событий нет). Redis не нужен (ADR-0056) |
Классификация
| Поле | Значение |
|---|
| Сервис | identity |
| Тип job | Scheduled (фоновый воркер, суточный таймер, ночное окно) |
| Trigger | внутренний таймер каждые RetentionInterval (~24 ч, ночное окно), конфиг |
| Критичность | low (housekeeping; деградация — рост таблиц и задержка вывода PII, не влияет на вход/авторизацию — они не зависят от наличия старых строк) |
Расписание и параллелизм
| Аспект | Правило |
|---|
| Schedule | каждые RetentionInterval (default 24 ч; UTC), запуск в ночное окно (NightWindowUtc, default 02:00–05:00) для минимизации конкуренции с онлайном; лаг вывода PII ≤ RetentionInterval + длительность прогона |
| Параллелизм | несколько экземпляров разбирают непересекающиеся порции (взятая порция блокируется, остальные её пропускают); повторное удаление невозможно (строки уже нет); отдельная координация не требуется |
| Batch size | BatchSize строк на порцию (default 5000); прогон повторяет выборку+удаление, пока порция полна — большой backlog разгребается за несколько итераций, длинные удаления не держат таблицу |
| Max duration | MaxRunDuration (default 30 мин) — предохранитель; при превышении прогон завершается штатно, остаток берёт следующая ночь |
Входная модель SessionRetentionOptions (config)
Внешнего runtime-входа нет; поведение задаётся конфигом. Актор — система (воркер); контекст вне tenant
(сущности не tenanted, изоляция record-level по UserId — ADR-0052,
user-session.md): скан по всем строкам под предикат ретенции, cross-owner
maintenance.
| Поле | Тип | Обязательность | Источник | Описание |
|---|
SicrRetention | TimeSpan | Да | config (регламент) | Возраст SICR к удалению по CreatedAt (> 0, default 30 дней). |
RefreshTerminalRetention | TimeSpan | Да | config (регламент) | Возраст терминальных refresh_sessions к удалению по UpdatedAt (> 0, default 90 дней). |
RetentionInterval | TimeSpan | Да | config | Период запуска (~24 ч, default 24 ч). |
BatchSize | int | Да | config | Размер порции удаления (> 0, default 5000). |
MaxRunDuration | TimeSpan | Да | config | Верхняя граница одного прогона (default 30 мин). |
Алгоритм
Фазы независимы (нет связи между sign_in_confirmation_requests и refresh_sessions), порядок не важен.
now фиксируется на старте прогона; отсечки cutoffSicr = now − SicrRetention,
cutoffRefresh = now − RefreshTerminalRetention.
- Контекст. Актор = система,
correlationId = jobRunId (на прогон), контекст вне tenant (cross-owner). Ключ
идемпотентности не нужен: физическое удаление идемпотентно по определению — повторный проход не находит уже
удалённых строк.
- Что читаем (порциями под блокировкой). Берётся только порция
Id строк-кандидатов:
- Фаза A — SICR: записи с
CreatedAt < cutoffSicr, порядок по CreatedAt (индекс ix_sicr_created_at).
Статус в предикат не входит — удаляются любые.
- Фаза B — refresh-терминалы:
Status ∈ {Rotated, Revoked, Expired} и UpdatedAt < cutoffRefresh, порядок
по UpdatedAt. Active не матчится ни при каком возрасте.
Параллельные экземпляры пропускают строки, взятые другим экземпляром/предыдущей незавершённой порцией.
- Правила. Переходов состояния нет — retention работает только с уже терминальными (refresh) или
инертными (SICR) строками и удаляет их. Домен-инвариантов к проверке нет (дом —
user-session.md); guard возраста/статуса целиком в предикате шага 2.
- Удаление. На порцию — удаление по набору
Id из шага 2 (число удалённых = метрика прохода). Порядок с
учётом FK:
- Фаза B, self-FK
ParentSessionId (Restrict): родителя нельзя удалить, пока на него ссылается живая
дочерняя строка. В предикат добавлен анти-ссылочный guard — удаляются только «листья» терминального под-леса
(нет ссылающегося потомка). Родитель, чей потомок ещё не устарел, забирается следующим прогоном (когда потомок
тоже станет терминальным и старым). Дренаж цепочки сходится за несколько ночей; при объёмах ADR-0056 цепочки
короткие.
- Фаза A: на SICR никто не ссылается — простое батч-удаление (связь
SICR → users — reference, удаление SICR
родителя не затрагивает; user не удаляется).
- Согласованность. Каждая порция — своё атомарное изменение. Кросс-фазового/кросс-порционного изменения нет —
фазы и порции независимы, частичный прогресс валиден и не откатывается.
- События. Нет. Удаление отработавших строк «тихое», side effects отсутствуют (в отличие от
SessionRevokedEvent/UserSignedInKafkaEvent, рождающихся на Revoke/вход). Потребители (user-activity) уже
получили терминальные статусы ранее — удаление старой строки их проекции не меняет.
- Результат. Наружу — метрики прогона (
sicrDeleted, refreshDeleted, durationMs, backlogRemaining).
Пустой проход — no-op success. Инфраструктурный сбой на порции — лог + переход к следующей фазе/тику; остаток берёт
следующая ночь (guard делает повтор идемпотентным).
Согласованность изменения
| Элемент | Правило |
|---|
| Граница | на порцию удаления; фазы A и B независимы |
| Сохраняемое состояние | удаление строк sign_in_confirmation_requests / refresh_sessions; иных таблиц не касается (никаких статусов) |
| Внешние вызовы | нет — только PostgreSQL. Шина событий/Redis не задействованы |
| Частичная ошибка | continue: сбой на порции/фазе логируется и не откатывает уже зафиксированные порции; строки остаются и берутся следующей ночью |
Идемпотентность и retry
| Сценарий | Правило |
|---|
| Job restart | Checkpoint не нужен — «прогресс» это факт отсутствия строки; рестарт заново фильтрует по cutoff, удалённые не находятся |
| Duplicate item | Невозможно: удалённая строка не матчит предикат повторно; повторный проход даёт 0 |
| Параллельные экземпляры | Безопасны: построчная/попорционная блокировка разводит порции; одну строку удаляет ровно один экземпляр |
| Гонка с онлайном | Retention трогает только терминалы (refresh) / записи старше 30 дней (SICR) — их не изменяют живые операции (confirm/refresh работают по Pending/Active); пересечения предикатов нет |
Self-FK ParentSessionId | Анти-ссылочный guard + Restrict исключают удаление родителя при живом потомке; цепочка дренится «от листа» за несколько прогонов, без FK-нарушений |
| Повторяемая ошибка | Инфраструктурный сбой — порция не удалена, следующий tick повторяет; без бесконечного retry внутри прогона |
Пред/постусловия
- До прогона. Могут существовать SICR старше
SicrRetention (любого статуса) и терминальные
RefreshSession (Status ∈ {Rotated, Revoked, Expired}) старше RefreshTerminalRetention, удерживающие
PII (PhoneNumber/UserAgent/IpAddress).
- После прогона. Такие строки физически удалены, кроме родителей цепочки ротаций, заблокированных
живым/молодым потомком (self-FK
Restrict — дренаж «от листа» за несколько ночей), и строк неудавшейся
порции. Active-сессии не тронуты при любом возрасте; статусы не менялись; событий нет; PII удалённых строк
выведена из БД (регламент хранения, compliance). Воркер ничего не ожидает и не предоставляет соседним
сервисам — потребители (user-activity) уже получили терминальные статусы ранее, удаление старой строки их
проекцию не меняет.
Наблюдаемость
| Тип | Правило |
|---|
| Logs | job started (jobRunId, cutoffSicr, cutoffRefresh); batch deleted (phase, deletedCount); phase failed (phase, exception); job completed (sicrDeleted, refreshDeleted, durationMs, backlogRemaining). PhoneNumber/IpAddress/UserAgent в логи не пишутся (PII — их же и выводим) |
| Metrics | identity_session_retention_sicr_deleted_total / _refresh_deleted_total (counter); identity_session_retention_run_duration_ms (histogram); identity_session_retention_backlog (gauge — строки под предикатом ретенции, не удалённые на конец прогона, по таблицам); identity_session_retention_errors_total |
| Traces | span на прогон с jobRunId, атрибуты phase, batchSize, deletedCount |
| Alerts | нет успешного прогона > 2 × RetentionInterval (воркер стоит → PII задерживается сверх регламента — compliance-алерт); identity_session_retention_backlog устойчиво растёт (не успевает разгребать); identity_session_retention_errors_total растёт |
SLO ретенции
| Показатель | Цель | Обоснование |
|---|
| Вывод PII в срок | 99% строк старше порога удалены за ≤ RetentionInterval + один прогон (SICR ≤ ~31 день, refresh-терминалы ≤ ~91 день от порога) | регламент хранения; проверяется метрикой backlog |
| Отсутствие «залежавшихся» строк | нет SICR старше SicrRetention + 2 × RetentionInterval; нет терминальных refresh старше RefreshTerminalRetention + 2 × RetentionInterval (кроме заблокированных живым потомком) | алерт + метрика backlog |
| Идемпотентность | 0 повторных/ошибочных удалений при рестартах и N экземплярах | предикат + попорционная блокировка; удаление идемпотентно |
| FK-целостность | 0 нарушений self-FK ParentSessionId при удалении цепочек | анти-ссылочный guard (шаг 4) |
Связанный runbook
| Сценарий | Runbook |
|---|
| Backlog растёт (воркер не успевает) | увеличить BatchSize/частоту либо сузить окно; проверить блокировки PostgreSQL и план по ix_sicr_created_at; при устойчивом росте — рассмотреть партиционирование |
| Воркер стоит (нет прогонов) | проверить сервис/pod, конфиг RetentionInterval/NightWindowUtc; compliance-риск — PII задерживается, но вход/авторизация не деградируют |
Рост errors_total | проверить доступность PostgreSQL/schema и FK-нарушения (self-FK цепочек); строки останутся и удалятся после восстановления |
Тесты
| Тест | Ожидание |
|---|
| Empty batch | нет строк под предикатом — no-op success, 0 удалений, событий нет |
| SICR старше 30 дней | удаляются независимо от статуса (Confirmed/Expired/Exhausted/Superseded/Pending); строки ≤ 30 дней остаются |
| SICR-статус не влияет | Confirmed-запрос возрастом 40 дней удалён так же, как Expired |
| Терминальный refresh старше 90 дней | Rotated/Revoked/Expired с UpdatedAt < cutoff удалены; Active любого возраста — остаётся |
| Refresh младше порога | терминальный Revoked возрастом 10 дней — не удаляется |
| Цепочка ротаций (self-FK) | родитель с живым/молодым потомком не удаляется (без FK-нарушения); удаляется после устаревания потомка |
| Идемпотентность/рестарт | повторный прогон по тем же данным удаляет 0 строк |
| Параллельные экземпляры | две копии воркера не удаляют одну строку/порцию дважды |
| Нет событий/статусов | ни одного события; ни один Status не изменён |
| Cancellation | остановка между порциями — graceful stop, зафиксированное сохранено |
Обратные ссылки
Автоссылки: где используется этот документ.
Связанные документы
- Дом полей/инвариантов/индексов/ретенции: ../domain/user-session.md
(
SignInConfirmationRequest/RefreshSession, статусы, ix_sicr_created_at, self-FK ParentSessionId,
раздел «Партиционирование / retention»), ../domain/user.md.
- Обзор/эксплуатация: ../README.md («jobs», «Alerts»); соседний воркер —
expire-sessions.md (терминализация статуса, без удаления),
expire-role-assignments.md.
- Витрина сессий пользователя (проекция статусов): ../../user-activity/README.md (ADR-0054).
- Решения: ADR-0052 (tenancy, record-level),
ADR-0056 (simplicity-first,
PostgreSQL-only, polling/batch, без Redis).