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

Background Job: ExpireSessionsJob

Назначение

ExpireSessionsJob — фоновый polling-воркер модуля аутентификации identity, который по TTL переводит в терминальный статус Expired две короткоживущие сущности user-session.md:

  1. RefreshSessionActive-сессии, у которых ExpiresAt <= now (абсолютный TTL 30 дней, sliding при ротации): Active → Expired.
  2. SignInConfirmationRequest (далее SICR) — Pending-запросы кодов, у которых ExpiresAt <= now (TTL 5 мин, INV-S3): Pending → Expired.
Экспирация «тихая»

Воркер лишь материализует статус для чистых выборок (список устройств user-activity.sessions.*) и метрик. Безопасность обеспечивается лениво в самих операциях — подтверждение кода проверяет ExpiresAt > now (INV-S3), обновление сессии проверяет Status == Active && ExpiresAt > now — независимо от того, успел ли воркер перевести статус. Поэтому лаг материализации не создаёт security-окна.

Отличать от соседних воркеров (README «jobs»):

  • retention (session-retention.md) — физическое удаление терминальных записей (SICR старше 30 дней, refresh_sessions старше 90 дней). Здесь — только смена статуса, без удаления.
  • access fan-out (access-fan-out.md) и expire-role-assignments (expire-role-assignments.md) — работают с RoleAssignment/правами, не с сессиями.

Metadata

ПолеЗначение
Статус документаdraft
Owning teamPIN Platform Core
Область действияконтекст identity, таблицы identity.refresh_sessions / identity.sign_in_confirmation_requests
Assumptions/ограничениясмена статуса групповым апдейтом не поднимает агрегат → доменные события не публикуются (приемлемо: у экспирации нет side effects, кроме флипа статуса); целевая нагрузка 1–10k активных (ADR-0056), партиционирования нет; в user-session.md переход SICR Pending → Expired подписан retention-job — это неточность подписи: материализацию статуса владеет этот воркер, физическое удаление — retention
ЗависимостиPostgreSQL (schema identity). Шина событий не используется (событий нет). Redis не нужен (ADR-0056)

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

ПолеЗначение
Сервисidentity
Тип jobRecurring (фоновый воркер с внутренним таймером)
Triggerвнутренний таймер каждые PollingInterval (~1–5 мин, конфиг)
Критичностьlow (материализация статуса; деградация задерживает чистоту выборок/метрик, безопасность не зависит от воркера — ленивые проверки в операциях)

Расписание и параллелизм

АспектПравило
Scheduleкаждые PollingInterval (default 5 мин; UTC); лаг материализации ≤ PollingInterval + длительность апдейта
Параллелизмбезопасен без отдельной координации: экспирация — атомарный групповой апдейт WHERE status = <guard> AND expires_at <= now, а status-guard делает повторный флип no-op — второй экземпляр увидит 0 строк под guard. При чанкинге экземпляры берут непересекающиеся порции
Batch sizeпо умолчанию один групповой апдейт на фазу. При большом backlog — чанк: выбрать порцию строк (под блокировкой, экземпляры разводят порции), затем сменить статус по этому набору
Max durationMaxRunDuration (default 2 мин) — предохранитель; при превышении прогон завершается штатно, остаток берёт следующий tick

Входная модель ExpireSessionsOptions (config)

Внешнего runtime-входа нет; поведение задаётся конфигом.

ПолеТипОбязательностьИсточникОписание
PollingIntervalTimeSpanДаconfigПериод запуска (~1–5 мин, default 5 мин).
BatchSizeintДаconfigРазмер чанка при чанкинге (> 0, default 5000); при малом backlog не используется.
MaxRunDurationTimeSpanДаconfigВерхняя граница одного прогона (default 2 мин).
Актор и область действия

Актор — система (воркер); контекст вне tenant (сущности не tenanted, изоляция record-level по UserId — ADR-0052, user-session.md): скан идёт по всем строкам под статус-guard.

Алгоритм

Фазы независимы, порядок не важен (нет связи между refresh_sessions и SICR). now фиксируется на старте прогона.

  1. Контекст. Актор = система, correlationId = jobRunId (на прогон), контекст вне tenant (cross-owner). Ключ идемпотентности не нужен: её даёт status-guard в условии апдейта (шаг 3), а не ключ.
  2. Что читаем. В базовом режиме агрегаты не грузятся — работает групповой апдейт. Чтение только для метрики backlog (шаг 5) и опционального чанкинга: порция строк по индексам ix_refresh_sessions_expires_at (partial where status = 'Active') / ix_sicr_expires_at.
  3. Правила и переходы. Переходы — дом user-session.md (здесь только маппинг). Guard входит в условие апдейта:
    • RefreshSession: guard Status == RefreshSessionStatus.Active && ExpiresAt <= nowStatus = RefreshSessionStatus.Expired. Поля RevokedAt/RevokedReason не трогаются (экспирация ≠ Revoke).
    • SignInConfirmationRequest: guard Status == ConfirmationRequestStatus.Pending && ExpiresAt <= nowStatus = ConfirmationRequestStatus.Expired. ConfirmedAt остаётся null.
  4. Что меняется. Групповой апдейт статуса на каждую фазу (число обновлённых строк = метрика прохода). Гонка с параллельным подтверждением (Pending → Confirmed) / обновлением сессии (Active → Rotated) разрешается на уровне строки: кто первым сменил статус, того и переход; проигравшая ветка воркера просто не матчит условие.
  5. Согласованность. Каждая фаза — один атомарный апдейт (либо, при чанкинге, изменение на чанк). Кросс-фазового изменения нет — фазы независимы, частичный прогресс валиден.
  6. События. Нет. Интеграционные события (topic pin.identity.<event>.v1) не публикуются — экспирация «тихая» (контраст: SessionRevokedEvent/UserSignedInKafkaEvent рождаются только на Revoke/вход). Доменных событий тоже нет: групповой апдейт не поднимает агрегат, а у экспирации нет side effects помимо флипа статуса (витрина user-activity читает актуальный статус; access-JWT отклоняется по своему exp/sid независимо).
  7. Результат. Наружу — метрики прогона (refreshExpired, sicrExpired, durationMs, backlogRemaining). Пустой проход — no-op success. Инфраструктурный сбой на фазе — лог + переход к следующей фазе; остаток берёт следующий tick.

Согласованность изменения

ЭлементПравило
ГраницаБазово — на апдейт (один statement на фазу); при чанкинге — на чанк
Сохраняемое состояниеrefresh_sessions.Status, sign_in_confirmation_requests.Status (только колонка статуса; прочие поля неизменны)
Внешние вызовыНет — только PostgreSQL. Шина событий/Redis не задействованы
Частичная ошибкаcontinue: сбой одной фазы/чанка логируется и не откатывает другую; строки остаются в исходном статусе и берутся следующим tick (guard делает повтор идемпотентным)

Идемпотентность и retry

СценарийПравило
Job restartCheckpoint не нужен — «прогресс» это сам Status = Expired; рестарт заново матчит условие, уже истёкшие не попадают под guard
Параллельные экземплярыБезопасны: status-guard в апдейте + построчная блокировка; при чанкинге экземпляры разводят порции. Двойной флип одной строки невозможен
Duplicate itemНевозможно: под guard Status == Active/Pending уже Expired-строка не матчит; повторный проход даёт 0
Гонка с confirm/refreshверсия + status-guard: Pending → Confirmed / Active → Rotated и → Expired взаимно исключают; выигрывает первый сменивший статус. Ленивая проверка ExpiresAt в операции закрывает случай «истёк, но ещё Pending/Active» независимо от воркера
Повторяемая ошибкаИнфраструктурный сбой — строки не тронуты, следующий tick повторяет; без бесконечного retry внутри прогона

Пред/постусловия

  • До прогона. Могут существовать RefreshSession Active с ExpiresAt <= now и SignInConfirmationRequest Pending с ExpiresAt <= now — истёкшие, но ещё не материализованные. Безопасность не зависит от прогона: операции подтверждения кода и обновления сессии проверяют TTL лениво (INV-S3; Status == Active && ExpiresAt > now).
  • После прогона. Все такие строки под guard переведены в Expired, кроме проигравших гонку с confirm/refresh (побеждает первый сменивший статус) и строк неудавшегося чанка (берутся следующим tick). Прочие поля не тронуты (RevokedAt/RevokedReason/ConfirmedAt = null), событий не отправлено, чистота выборок user-activity.sessions.* и метрик восстановлена. Воркер ничего не ожидает и ничего не предоставляет соседним сервисам (событий нет; витрина user-activity читает статус проекцией).

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

ТипПравило
Logsjob started (jobRunId); phase done (phase, expiredCount); phase failed (phase, exception); job completed (refreshExpired, sicrExpired, durationMs, backlogRemaining). PhoneNumber/IpAddress/UserAgent в логи не пишутся (PII)
Metricsidentity_expire_sessions_refresh_expired_total / _sicr_expired_total (counter); identity_expire_sessions_run_duration_ms (histogram); identity_expire_sessions_backlog (gauge — строки под guard с ExpiresAt <= now, ещё не материализованные, на конец прогона); identity_expire_sessions_errors_total
Tracesspan на прогон с jobRunId, атрибуты phase, expiredCount
Alertsнет успешного прогона > 2 × PollingInterval (воркер стоит); identity_expire_sessions_backlog растёт (материализация отстаёт); identity_expire_sessions_errors_total растёт. Security-алерта на лаг нет — ленивые проверки в операциях

SLO точности

ПоказательЦельОбоснование
Лаг материализацииPollingInterval + длительность апдейта (≈ 1–6 мин) для 99% строкpolling-delta; без security-влияния (ленивые проверки)
Отсутствие «залипших» статусовнет строк с ExpiresAt старше 2 × PollingInterval под активным guardалерт + метрика backlog
Идемпотентность0 повторных флипов при рестартах и N экземплярахstatus-guard + версия

Связанный runbook

СценарийRunbook
Backlog растёт (воркер не успевает)включить/уменьшить BatchSize (чанкинг), проверить блокировки PostgreSQL и планы по ix_*_expires_at
Воркер стоит (нет прогонов)проверить сервис/pod, конфиг PollingInterval; безопасность не деградирует (операции проверяют TTL лениво)
Рост errors_totalпроверить доступность PostgreSQL/schema; строки останутся в исходном статусе и материализуются после восстановления

Тесты

ТестОжидание
Empty batchнет строк под guard — no-op success, 0 обновлений
Истёкшая refresh-сессияActive+ExpiresAt <= nowExpired; RevokedAt/RevokedReason = null (не Revoke); события не публикуются
Истёкший SICRPending+ExpiresAt <= nowExpired; ConfirmedAt = null
Идемпотентность/рестартповторный прогон по тем же данным даёт 0 обновлений
Параллельные экземплярыдве копии воркера не флипают строку дважды (status-guard/построчная блокировка)
Гонка с confirmодновременное подтверждение кода и проход воркера: побеждает один переход; истёкший запрос отклонён IDENTITY_CONFIRMATION_EXPIRED (INV-S3) независимо от статуса
Нет событийни интеграционных, ни доменных событий — экспирация «тихая»
Cancellationостановка между фазами — graceful stop, апдейт не рвётся

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

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

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

  • Дом полей/инвариантов/переходов: ../domain/user-session.md (RefreshSession/SignInConfirmationRequest, INV-S3, переходы, индексы ix_refresh_sessions_expires_at / ix_sicr_expires_at), ../domain/user.md.
  • Обзор/эксплуатация: ../README.md («jobs», «Alerts»); соседние воркеры — session-retention.md (физическое удаление), access-fan-out.md, expire-role-assignments (../domain/role.md).
  • Витрина сессий пользователя (потребитель статуса): docs/06-services/user-activity/ (ADR-0054).
  • Решения: ADR-0052 (tenancy), ADR-0056 (simplicity-first, PostgreSQL-only, polling, без Redis).