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

Background Job: ExpireRoleAssignmentsJob

Назначение

ExpireRoleAssignmentsJob — периодический фоновый воркер ролевой модели контекста identity, который переводит временные назначения RoleAssignment Active → Expired по наступлении ExpiresAt (напр. арбитраж заботы, временный доступ менеджера). Реализует переход role.md Active → Expired [ExpiresAt <= now] и enum-значение RoleAssignmentStatus.Expired. Механизм — периодический скан по индексу ix_role_assignments_expires_at вместо online-expiry (ADR-0056: PostgreSQL-only, без Redis/TTL).

На каждое экспирированное назначение поднимается доменное событие RoleAssignmentRevokedEvent (причина Expired); на каждого затронутого пользователя — интеграционное UserAccessChangedKafkaEvent (user-access-changed.md, pin.identity.access.v1, key = UserId) для инвалидации кэшей авторизации у потребителей (gateway/crm/catalog/chessboard). Записи не удаляются — история назначений хранится бессрочно (аудит доступов, role.md «записи не удаляются, только статус»).

MetadataЗначение
Owning teamPIN Platform Core
Статус документаdraft
Область действияконтекст identity, таблица identity.role_assignments
Assumptions/ограничениялаг экспирации ограничен PollingInterval (доступ живёт до ExpiresAt + один цикл) — приемлемо для арбитража/временных ролей, точной секундной отсечки нет; целевой объём временных назначений — сотни/тысячи строк, партиционирования нет
ЗависимостиPostgreSQL (identity); шина событий; события потребляют gateway/crm/catalog/chessboard (см. user-access-changed.md)

Не покрывает истечение refresh-сессий — это отдельный агрегат RefreshSession и соседний воркер expire-sessions (user-session.md); здесь — только RoleAssignment.

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

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

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

АспектПравило
Scheduleкаждые PollingInterval (default 5 мин; UTC); лаг экспирации ≤ PollingInterval
Параллелизмнесколько экземпляров берут непересекающиеся назначения (взятая строка блокируется, остальные её пропускают) — дубль-перехода нет; отдельная распределённая координация не требуется
Batch sizeBatchSize строк на выборку (default 500); прогон повторяет выборку, пока батч полон — большой backlog разгребается за несколько итераций
Max durationMaxRunDuration (default 4 мин, ниже следующего tick) — предохранитель; при превышении прогон завершается штатно, остаток берёт следующий tick

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

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

ПолеТипОбязательностьИсточникОписание
PollingIntervalTimeSpanДаconfig (ADR-0056)Период запуска (~5 мин, default 00:05:00).
BatchSizeintДаconfigРазмер выборки строк за проход (> 0, default 500).
MaxRunDurationTimeSpanДаconfigВерхняя граница одного прогона (default 4 мин).
Актор и область действия

Актор — система (воркер); контекст вне tenant (ADR-0052): скан идёт по всем Active-назначениям с истёкшим ExpiresAt безотносительно CompanyId — задокументированный system-maintenance cross-tenant проход, публичного входа у джобы нет.

Алгоритм

Отсечка now фиксируется на старте прогона; дом полей/инвариантов — role.md, здесь — только маппинг перехода.

  1. Контекст. Актор = система, correlationId = jobRunId (на прогон), контекст вне tenant. Ключ идемпотентности не нужен: её обеспечивают фильтр Status == Active, guard перехода и построчная блокировка (шаги 2–3), а не ключ.
  2. Что читаем (под блокировкой). Взять страницу назначений: Status = Active, ExpiresAt != null, ExpiresAt <= now, порядок по ExpiresAt (самые просроченные первыми), размер BatchSize — план идёт по partial-индексу ix_role_assignments_expires_at. Параллельные экземпляры пропускают уже взятые строки.
  3. Правила и переходы. На каждый загруженный агрегат — экспирация: guard Status == Active (иначе no-op — идемпотентность); переход Status: Active → Expired; UpdatedAt = now. Поля RevokedAt/RevokedByUserId не заполняются — это поля отзыва актором, не экспирации. Поднимается доменное RoleAssignmentRevokedEvent (Reason = RoleAssignmentRevokeReason.Expired). Переход выполняется на загруженном агрегате (не «слепым» bulk-update), т.к. нужны доменные и per-user интеграционные события.
  4. Что меняется. Изменяются только сами назначения (переход статуса). Оптимистичный контроль версий: конкурентный Revoke тем же назначением между выборкой и фиксацией даёт конфликт версий → строка пропускается, берётся следующим tick (её выигранный статус не перетирается).
  5. События. В том же согласованном изменении:
    • доменные — RoleAssignmentRevokedEvent (причина Expired) на каждое назначение;
    • интеграционные — на каждого затронутого пользователя батча (дедуп по UserId) один UserAccessChangedKafkaEvent (ContractVersion = 1, topic pin.identity.access.v1, AggregateId = UserId, AggregateType = "User", key = UserId); ChangeReason = AccessChangeReason.AssignmentExpired, Version = max(RoleAssignment.Version) затронутых назначений пользователя, ActiveAssignments — снапшот его оставшихся активных назначений после экспирации. Payload/поля — дом user-access-changed.md.
  6. Согласованность. Граница — на батч: все экспирированные назначения батча + их доменные события + по одному интеграционному событию на пользователя фиксируются атомарно. Снапшот ActiveAssignments собирается до фиксации; события уходят в шину после (at-least-once).
  7. Результат. Наружу — метрики прогона (экспирировано назначений, затронуто пользователей, длительность, остался ли backlog). Пустой прогон — no-op success. Инфраструктурный сбой на батче — лог + откат этого батча (строки остаются Active), остаток берёт следующий tick; частичный прогресс зафиксированных батчей сохраняется.

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

ЭлементПравило
Границана батч: все Expired-назначения батча + доменные события + per-user UserAccessChangedKafkaEvent
Сохраняемое состояниеrole_assignments (Status/UpdatedAt/Version) + исходящие user-access-changed
Публикация событийпосле фиксации изменения (at-least-once); синхронных внешних вызовов внутри изменения нет
Частичная ошибкаcontinue: сбой на одном батче логируется и не откатывает уже зафиксированные; строки остаются Active и повторно берутся следующим tick

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

СценарийПравило
Job restartCheckpoint не нужен — «прогресс» это сам факт Status = Expired; рестарт заново фильтрует Active с ExpiresAt <= now, уже экспирированные не попадают
Параллельные экземплярыБезопасны: построчная блокировка разводит строки; одно назначение экспирирует ровно один экземпляр
Duplicate itemНевозможно в прогоне: выбираются только Active; guard экспирации — no-op при Status != Active; повторный проход даёт 0 совпадений
Гонка с RevokeRevoke актором между выборкой и фиксацией выигрывает по версии: строка пропускается, остаётся в статусе, выставленном актором (Revoked) — экспирация её не перетирает
Дубликат событияСнапшот-семантика UserAccessChangedKafkaEvent: потребитель применяет последнее по (UserId, Version)-guard, отбрасывает меньшие; доставка at-least-once

Кросс-сервисный контракт

НаправлениеГарантия
Ожидает (вход)Ничего от соседних сервисов — воркер читает только собственную таблицу role_assignments. Единственное предположение: временные назначения проставляют ExpiresAt > GrantedAt (дом — role.md).
Предоставляет (выход)На каждого затронутого пользователя батча — один UserAccessChangedKafkaEvent (ChangeReason = AssignmentExpired, Version = max(RoleAssignment.Version) его экспирированных назначений, ActiveAssignments — снапшот после экспирации; key = UserId, pin.identity.access.v1). Потребители (gateway/crm/catalog/chessboard) сбрасывают кэш прав < 1 мин; снапшот self-healing.
Гонка с архивом роли и ручным Revoke

Каскад архива роли (INV-R5) и ручной Revoke — тоже переходы Active → *; версионный guard разводит их с экспирацией, побеждает первый зафиксировавший. Экспирация проигранную строку не перетирает (остаётся Revoked), пользователь получит корректный снапшот от победившей операции.

Экспирация не рвёт вход

Истечение назначения только сужает эффективные права — активные RefreshSession не отзываются (это другой агрегат/воркер, expire-sessions); доступ сузится после инвалидации кэша прав у потребителей, вход по действующей сессии продолжает работать.

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

ТипПравило
Logsjob started (jobRunId, now); batch processed (rows, expired, affectedUsers); batch failed (jobRunId, exception); job completed (expiredAssignments, affectedUsers, durationMs, backlogRemaining). PII (телефоны/ФИО) в логи не пишутся — только UserId/AssignmentId
Metricsidentity_role_assignments_expired_total (counter — количество экспирированных назначений); identity_expire_assignments_run_duration_ms (histogram); identity_expire_assignments_backlog (gauge — Active c ExpiresAt <= now, осталось после прогона); identity_expire_assignments_errors_total; identity_expire_assignments_lag_seconds (now − ExpiresAt на экспирации — контроль SLO)
Tracesspan на прогон с jobRunId, атрибуты batchSize, expiredCount, affectedUsers; вложенные span на батч
Alerts«залипший backlog» — Active с ExpiresAt старше 2 × PollingInterval (воркер не успевает/стоит); identity_expire_assignments_errors_total растёт; отсутствие успешного прогона > PollingInterval; backlog pin.identity.access.v1 > 1000 (см. ../README.md «Alerts»)

SLO

ПоказательЦельОбоснование
Лаг экспирацииPollingInterval (≈ 5 мин) для 99% назначенийpolling-delta ADR-0056; проверяется identity_expire_assignments_lag_seconds
Отсутствие «вечных» Activeнет Active с ExpiresAt старше 2 × PollingIntervalалерт + метрика backlog
Идемпотентность0 повторных переходов/дублей статуса при рестартах и N экземплярахфильтр Active + guard экспирации + построчная блокировка
Инвалидация кэшейat-least-once UserAccessChangedKafkaEvent на затронутого пользователя; дедуп у потребителя(UserId, Version)-guard консюмера

Связанный runbook

СценарийRunbook
Backlog растёт (воркер не успевает)увеличить BatchSize/частоту, проверить блокировки PostgreSQL и лаг доставки событий
Воркер стоит (нет прогонов)проверить сервис/pod, конфиг PollingInterval
Массовый рост errors_totalпроверить доступность PostgreSQL/схемы identity, откаты; строки останутся Active и экспирируют после восстановления
Права не сбрасываются у потребителяпроверить лаг топика pin.identity.access.v1, DLQ консюмеров, (UserId, Version)-guard

Тесты

ТестОжидание
Empty batchнет Active с ExpiresAt <= now — no-op success, событий нет
Экспирация назначенияStatus: Active → Expired, запись сохранена (не удалена), RoleAssignmentRevokedEvent(Expired) + UserAccessChangedKafkaEvent(AssignmentExpired)
Несколько назначений одного пользователяодно UserAccessChangedKafkaEvent на UserId со снапшотом всех оставшихся Active после экспирации
Постоянное назначение (ExpiresAt == null)не затрагивается (partial-индекс/фильтр отсекают)
Идемпотентность/рестартповторный прогон по тем же данным экспирирует 0 строк, дублей событий нет
Параллельные экземплярыдве копии воркера не экспирируют одно назначение дважды
Гонка с Revokeконкурентный Revoke выигрывает по версии; назначение остаётся Revoked, не перетирается Expired
Cancellationостановка между батчами — graceful stop, зафиксированное сохранено
Партиал-фейлсбой на одном батче не откатывает соседние; сбойные остаются Active

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

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

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

  • Дом полей/инвариантов/переходов: ../domain/role.md (RoleAssignment, RoleAssignmentStatus.Expired, переход Active → Expired, индекс ix_role_assignments_expires_at, RoleAssignmentRevokedEvent); соседний агрегат — ../domain/user-session.md (RefreshSession, воркер expire-sessions).
  • Событие: ../events/user-access-changed.md (UserAccessChangedKafkaEvent, AccessChangeReason.AssignmentExpired, снапшот, consumers).
  • Обзор/эксплуатация: ../README.md («Background jobs», «Alerts»); соседние воркеры — expire-sessions (истечение RefreshSession), retention, access fan-out (RolePermissionsChanged).
  • Решения: ADR-0052 (вне tenant, TenantId = CompanyId), ADR-0056 (polling-экспирация, PostgreSQL-only, без Redis).