Background Job: ExpireSessionsJob
Назначение
ExpireSessionsJob — фоновый polling-воркер модуля аутентификации identity, который по TTL переводит в
терминальный статус Expired две короткоживущие сущности user-session.md:
RefreshSession—Active-сессии, у которыхExpiresAt <= now(абсолютный TTL 30 дней, sliding при ротации):Active → Expired.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 team | PIN 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 |
| Тип job | Recurring (фоновый воркер с внутренним таймером) |
| 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 duration | MaxRunDuration (default 2 мин) — предохранитель; при превышении прогон завершается штатно, остаток берёт следующий tick |
Входная модель ExpireSessionsOptions (config)
Внешнего runtime-входа нет; поведение задаётся конфигом.
| Поле | Тип | Обязательность | Источник | Описание |
|---|---|---|---|---|
PollingInterval | TimeSpan | Да | config | Период запуска (~1–5 мин, default 5 мин). |
BatchSize | int | Да | config | Размер чанка при чанкинге (> 0, default 5000); при малом backlog не используется. |
MaxRunDuration | TimeSpan | Да | config | Верхняя граница одного прогона (default 2 мин). |
Актор — система (воркер); контекст вне tenant (сущности не tenanted, изоляция record-level по UserId —
ADR-0052, user-session.md): скан идёт по всем строкам под статус-guard.
Алгоритм
Фазы независимы, порядок не важен (нет связи между refresh_sessions и SICR). now фиксируется на старте прогона.
- Контекст. Актор = система,
correlationId = jobRunId(на прогон), контекст вне tenant (cross-owner). Ключ идемпотентности не нужен: её даёт status-guard в условии апдейта (шаг 3), а не ключ. - Что читаем. В базовом режиме агрегаты не грузятся — работает групповой апдейт. Чтение только для метрики
backlog (шаг 5) и опционального чанкинга: порция строк по индексам
ix_refresh_sessions_expires_at(partialwhere status = 'Active') /ix_sicr_expires_at. - Правила и переходы. Переходы — дом user-session.md (здесь только маппинг). Guard
входит в условие апдейта:
RefreshSession: guardStatus == RefreshSessionStatus.Active && ExpiresAt <= now→Status = RefreshSessionStatus.Expired. ПоляRevokedAt/RevokedReasonне трогаются (экспирация ≠ Revoke).SignInConfirmationRequest: guardStatus == ConfirmationRequestStatus.Pending && ExpiresAt <= now→Status = ConfirmationRequestStatus.Expired.ConfirmedAtостаётсяnull.
- Что меняется. Групповой апдейт статуса на каждую фазу (число обновлённых строк = метрика прохода). Гонка с
параллельным подтверждением (
Pending → Confirmed) / обновлением сессии (Active → Rotated) разрешается на уровне строки: кто первым сменил статус, того и переход; проигравшая ветка воркера просто не матчит условие. - Согласованность. Каждая фаза — один атомарный апдейт (либо, при чанкинге, изменение на чанк). Кросс-фазового изменения нет — фазы независимы, частичный прогресс валиден.
- События. Нет. Интеграционные события (topic
pin.identity.<event>.v1) не публикуются — экспирация «тихая» (контраст:SessionRevokedEvent/UserSignedInKafkaEventрождаются только на Revoke/вход). Доменных событий тоже нет: групповой апдейт не поднимает агрегат, а у экспирации нет side effects помимо флипа статуса (витринаuser-activityчитает актуальный статус; access-JWT отклоняется по своемуexp/sidнезависимо). - Результат. Наружу — метрики прогона (
refreshExpired,sicrExpired,durationMs,backlogRemaining). Пустой проход — no-op success. Инфраструктурный сбой на фазе — лог + переход к следующей фазе; остаток берёт следующий tick.
Согласованность изменения
| Элемент | Правило |
|---|---|
| Граница | Базово — на апдейт (один statement на фазу); при чанкинге — на чанк |
| Сохраняемое состояние | refresh_sessions.Status, sign_in_confirmation_requests.Status (только колонка статуса; прочие поля неизменны) |
| Внешние вызовы | Нет — только PostgreSQL. Шина событий/Redis не задействованы |
| Частичная ошибка | continue: сбой одной фазы/чанка логируется и не откатывает другую; строки остаются в исходном статусе и берутся следующим tick (guard делает повтор идемпотентным) |
Идемпотентность и retry
| Сценарий | Правило |
|---|---|
| Job restart | Checkpoint не нужен — «прогресс» это сам 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 внутри прогона |
Пред/постусловия
- До прогона. Могут существовать
RefreshSessionActiveсExpiresAt <= nowиSignInConfirmationRequestPendingсExpiresAt <= now— истёкшие, но ещё не материализованные. Безопасность не зависит от прогона: операции подтверждения кода и обновления сессии проверяют TTL лениво (INV-S3;Status == Active && ExpiresAt > now). - После прогона. Все такие строки под guard переведены в
Expired, кроме проигравших гонку сconfirm/refresh(побеждает первый сменивший статус) и строк неудавшегося чанка (берутся следующим tick). Прочие поля не тронуты (RevokedAt/RevokedReason/ConfirmedAt=null), событий не отправлено, чистота выборокuser-activity.sessions.*и метрик восстановлена. Воркер ничего не ожидает и ничего не предоставляет соседним сервисам (событий нет; витринаuser-activityчитает статус проекцией).
Наблюдаемость
| Тип | Правило |
|---|---|
| Logs | job started (jobRunId); phase done (phase, expiredCount); phase failed (phase, exception); job completed (refreshExpired, sicrExpired, durationMs, backlogRemaining). PhoneNumber/IpAddress/UserAgent в логи не пишутся (PII) |
| Metrics | identity_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 |
| Traces | span на прогон с 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 <= now → Expired; RevokedAt/RevokedReason = null (не Revoke); события не публикуются |
| Истёкший SICR | Pending+ExpiresAt <= now → Expired; ConfirmedAt = null |
| Идемпотентность/рестарт | повторный прогон по тем же данным даёт 0 обновлений |
| Параллельные экземпляры | две копии воркера не флипают строку дважды (status-guard/построчная блокировка) |
| Гонка с confirm | одновременное подтверждение кода и проход воркера: побеждает один переход; истёкший запрос отклонён IDENTITY_CONFIRMATION_EXPIRED (INV-S3) независимо от статуса |
| Нет событий | ни интеграционных, ни доменных событий — экспирация «тихая» |
| Cancellation | остановка между фазами — graceful stop, апдейт не рвётся |
Обратные ссылки
Автоссылки: где используется этот документ.
- Фоновые задачи (1): Background Job: SessionRetentionJob
Связанные документы
- Дом полей/инвариантов/переходов: ../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).