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 team | PIN 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 |
| Тип job | Recurring (фоновый воркер с внутренним таймером) |
| Trigger | внутренний таймер каждые PollingInterval (~5 мин, конфиг) |
| Критичность | medium (безопасное сужение прав; деградация продлевает временный доступ, не блокирует вход/авторизацию) |
Расписание и параллелизм
| Аспект | Правило |
|---|---|
| Schedule | каждые PollingInterval (default 5 мин; UTC); лаг экспирации ≤ PollingInterval |
| Параллелизм | несколько экземпляров берут непересекающиеся назначения (взятая строка блокируется, остальные её пропускают) — дубль-перехода нет; отдельная распределённая координация не требуется |
| Batch size | BatchSize строк на выборку (default 500); прогон повторяет выборку, пока батч полон — большой backlog разгребается за несколько итераций |
| Max duration | MaxRunDuration (default 4 мин, ниже следующего tick) — предохранитель; при превышении прогон завершается штатно, остаток берёт следующий tick |
Входная модель ExpireRoleAssignmentsOptions (config)
Внешнего runtime-входа нет; поведение задаётся конфигом.
| Поле | Тип | Обязательность | Источник | Описание |
|---|---|---|---|---|
PollingInterval | TimeSpan | Да | config (ADR-0056) | Период запуска (~5 мин, default 00:05:00). |
BatchSize | int | Да | config | Размер выборки строк за проход (> 0, default 500). |
MaxRunDuration | TimeSpan | Да | config | Верхняя граница одного прогона (default 4 мин). |
Актор — система (воркер); контекст вне tenant (ADR-0052): скан идёт по всем Active-назначениям с
истёкшим ExpiresAt безотносительно CompanyId — задокументированный system-maintenance cross-tenant проход,
публичного входа у джобы нет.
Алгоритм
Отсечка now фиксируется на старте прогона; дом полей/инвариантов — role.md, здесь —
только маппинг перехода.
- Контекст. Актор = система,
correlationId = jobRunId(на прогон), контекст вне tenant. Ключ идемпотентности не нужен: её обеспечивают фильтрStatus == Active, guard перехода и построчная блокировка (шаги 2–3), а не ключ. - Что читаем (под блокировкой). Взять страницу назначений:
Status = Active,ExpiresAt != null,ExpiresAt <= now, порядок поExpiresAt(самые просроченные первыми), размерBatchSize— план идёт по partial-индексуix_role_assignments_expires_at. Параллельные экземпляры пропускают уже взятые строки. - Правила и переходы. На каждый загруженный агрегат — экспирация: guard
Status == Active(иначе no-op — идемпотентность); переходStatus: Active → Expired;UpdatedAt = now. ПоляRevokedAt/RevokedByUserIdне заполняются — это поля отзыва актором, не экспирации. Поднимается доменноеRoleAssignmentRevokedEvent(Reason = RoleAssignmentRevokeReason.Expired). Переход выполняется на загруженном агрегате (не «слепым» bulk-update), т.к. нужны доменные и per-user интеграционные события. - Что меняется. Изменяются только сами назначения (переход статуса). Оптимистичный контроль версий:
конкурентный
Revokeтем же назначением между выборкой и фиксацией даёт конфликт версий → строка пропускается, берётся следующим tick (её выигранный статус не перетирается). - События. В том же согласованном изменении:
- доменные —
RoleAssignmentRevokedEvent(причинаExpired) на каждое назначение; - интеграционные — на каждого затронутого пользователя батча (дедуп по
UserId) одинUserAccessChangedKafkaEvent(ContractVersion = 1, topicpin.identity.access.v1,AggregateId = UserId,AggregateType = "User", key =UserId);ChangeReason = AccessChangeReason.AssignmentExpired,Version = max(RoleAssignment.Version)затронутых назначений пользователя,ActiveAssignments— снапшот его оставшихся активных назначений после экспирации. Payload/поля — дом user-access-changed.md.
- доменные —
- Согласованность. Граница — на батч: все экспирированные назначения батча + их доменные события + по
одному интеграционному событию на пользователя фиксируются атомарно. Снапшот
ActiveAssignmentsсобирается до фиксации; события уходят в шину после (at-least-once). - Результат. Наружу — метрики прогона (экспирировано назначений, затронуто пользователей, длительность, остался
ли 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 restart | Checkpoint не нужен — «прогресс» это сам факт Status = Expired; рестарт заново фильтрует Active с ExpiresAt <= now, уже экспирированные не попадают |
| Параллельные экземпляры | Безопасны: построчная блокировка разводит строки; одно назначение экспирирует ровно один экземпляр |
| Duplicate item | Невозможно в прогоне: выбираются только Active; guard экспирации — no-op при Status != Active; повторный проход даёт 0 совпадений |
| Гонка с Revoke | Revoke актором между выборкой и фиксацией выигрывает по версии: строка пропускается, остаётся в статусе, выставленном актором (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. |
Каскад архива роли (INV-R5) и ручной Revoke — тоже переходы Active → *; версионный guard разводит их с
экспирацией, побеждает первый зафиксировавший. Экспирация проигранную строку не перетирает (остаётся
Revoked), пользователь получит корректный снапшот от победившей операции.
Истечение назначения только сужает эффективные права — активные RefreshSession не отзываются (это другой
агрегат/воркер, expire-sessions); доступ сузится после инвалидации кэша прав у потребителей, вход по
действующей сессии продолжает работать.
Наблюдаемость
| Тип | Правило |
|---|---|
| Logs | job started (jobRunId, now); batch processed (rows, expired, affectedUsers); batch failed (jobRunId, exception); job completed (expiredAssignments, affectedUsers, durationMs, backlogRemaining). PII (телефоны/ФИО) в логи не пишутся — только UserId/AssignmentId |
| Metrics | identity_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) |
| Traces | span на прогон с 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 |
Обратные ссылки
Автоссылки: где используется этот документ.
- Фоновые задачи (2): Background Job: ExpireSessionsJob, Background Job: SessionRetentionJob
Связанные документы
- Дом полей/инвариантов/переходов: ../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).