Background Job: SlotExpirationJob
Назначение
SlotExpirationJob — фоновый polling-worker контекста calendar (slot-expiration), гигиеническая
уборка витрины: слоты доступности в статусе Available, чьё начало уже наступило (StartAt ≤ now),
переводятся в Expired (availability-slot.md, INV-SLT-5). Прошедшие
невостребованные слоты перестают числиться свободными и не раздувают рабочие выборки; риск роста числа
строк, отмеченный в ADR-0064 (раздел
рисков), закрывается этим worker-ом. Корректность бронирования от worker-а не зависит: витрина
отдаёт только слоты с запасом lead time, а book-slot отклоняет просроченные кандидаты guard-ами
INV-SLT-3/5 независимо от того, успел ли worker. Интеграционных событий слот не публикует
(availability-slot.md, «Доменные и интеграционные события»).
| Поле | Значение |
|---|
| Owner | PIN Platform (backend) |
| Статус документа | draft |
| Область действия | worker slot-expiration, слоты доступности контекста calendar; исходящих топиков нет |
| Assumptions / ограничения | рост числа слотов ограничен лимитом батча публикации (≤ 500) и горизонтом (ADR-0064); точность перехода не влияет на корректность бронирования (guard-ы на операции) |
| Зависимости | PostgreSQL (слоты) |
Классификация
| Поле | Значение |
|---|
| Сервис | calendar |
| Тип job | Recurring (polling-worker) |
| Trigger | цикл 5 мин (без внешнего триггера/топика) |
| Критичность | tier-3 maintenance (отставание не ломает ни данные, ни бронирование — только гигиена выборок) |
Расписание и Locking
| Аспект | Правило |
|---|
| Schedule | каждые 5 мин (UTC-часы) — точность секунд не нужна (guard-ы бронирования не зависят от worker-а); следующий тик не стартует, пока идёт предыдущий цикл |
| Distributed lock | нет глобального lock (ADR-0056 — без Redis); параллельные инстансы координируются построчной блокировкой SKIP LOCKED — каждая строка достаётся ровно одному инстансу |
| Batch size | 500 строк/цикл (SlotExpirationOptions.BatchSize); backlog добирается следующими циклами (order by StartAt asc) |
| Max duration | цель < 30 с/цикл; при превышении — тик пропускается, добор в следующем цикле |
| Cancellation | остановка хоста проходит в каждый I/O; текущий batch докоммичивается или откатывается целиком, блокировки освобождаются |
Входные параметры цикла
| Поле | Тип | Обязательность | Источник | Описание |
|---|
AsOf | DateTimeOffset | Да | системные часы | Момент цикла для сравнения со StartAt. |
BatchSize | int | Да | SlotExpirationOptions.BatchSize (default 500) | Верхняя граница строк за цикл. |
TenantId | — | Нет | не применяется | Cross-tenant scan: платформенный system-worker без tenant-фильтра (ADR-0052 п.3) — слоты всех агентств и консультантов ПИН. |
Алгоритм
- Контекст. Actor = system (cross-tenant разрешён явно);
correlationId = job run id; AsOf =
системные часы. Бизнес-идемпотентность обеспечивается предикатом статуса («истекают только
Available»); отдельный idempotency-ключ не нужен.
- Проверка входа. Внешнего ввода нет; допустимость состояния встроена в предикат выборки:
Status = Available && StartAt ≤ AsOf (индекс ix_availability_slots_status_start). Booked-слоты
не выбираются — их судьбу решают каскады записи (appointment.md,
INV-SLT-4); Expired/Archived терминальны для worker-а.
- Что читаем. Выборка под построчной блокировкой
SKIP LOCKED, порядок по StartAt asc, лимит
BatchSize. SKIP LOCKED пропускает строки, залоченные другим инстансом или in-flight book-slot
на границе StartAt — без ожидания.
- Бизнес-правила и переходы. Для каждого слота — переход
Available → Expired (INV-SLT-5,
идемпотентный): Status ← Expired, UpdatedAt ← AsOf, версия +1. Слот скрывается из витрины и
рабочих выборок; агент может добить его в Archived вручную (уборка,
availability-slot.md, stateDiagram).
- Что меняется. Обновляются
Status/UpdatedAt/Version слотов batch-а (уже заблокированных).
Напоминаний и календарных событий у невостребованного слота нет — каскадов нет.
- События. Не публикуются: занятие/освобождение слотов видно downstream только через события записи
pin.calendar.appointment-*; истечение невостребованного слота — внутреннее дело контекста.
- Согласованность. Граница — на батч: все переходы батча фиксируются атомарно; блокировки
держатся до commit. Poison-строка исключается из батча, логируется и остаётся на следующий цикл;
остальной батч коммитится (
continue, не stop).
- Результат.
{ ExpiredCount, SkippedLockedCount, DurationMs } — для метрик/логов.
Инфраструктурная ошибка → откат записи, освобождение блокировок; ретрай — следующим циклом (те же
строки ещё Available → выберутся снова, идемпотентно).
Граница транзакции
| Элемент | Правило |
|---|
| Единица согласованности | per batch; блокировки строк живут до commit |
| Сохраняемое состояние | переходы слотов (Status/UpdatedAt/Version); outbox не задействован (событий нет) |
| External calls | нет |
| Частичная ошибка | continue — poison-строка исключается из батча и логируется, остальной батч коммитится; инфраструктурный сбой — откат всего батча, ретрай следующим циклом |
Постусловия и цепочка следствий
Предусловия цикла. Существуют Available-слоты с наступившим StartAt (опубликованы батчами и не
востребованы).
Постусловия (после commit батча). Каждый обработанный слот в Expired; строка больше не выбирается
ни предикатом worker-а, ни витриной (../api/public/list-available-slots.md, путь по конвенции), ни
проверкой пересечений батч-генерации (INV-SLT-2 учитывает только Available/Booked).
Цепочка следствий. Отсутствует: downstream-потребителей у перехода нет, события не публикуются.
Единственный видимый эффект — гигиена выборок агента (../api/agent/list-slots.md, путь по конвенции) и
снятие просроченных строк из проверки пересечений при следующей публикации батча.
Что видит пользователь в промежутке. Между StartAt и срабатыванием worker-а (до ~1 цикла, 5 мин)
слот числится Available, но на витрину уже не попадает (фильтр lead time StartAt ≥ now + MinLeadTimeMinutes на выборке) и забронирован быть не может (CAL_SLOT_LEAD_TIME_VIOLATION, INV-SLT-5)
— расхождение чисто косметическое.
Идемпотентность и retry
| Сценарий | Правило |
|---|
| Job restart / повторный цикл | Checkpoint не нужен: выборка отбирает только Available, закоммиченный переход делает строку невыбираемой → повтор безопасен (idempotent by state; повторный проход над Expired/Archived — no-op, availability-slot.md «Конкуренция и идемпотентность») |
| Duplicate item (параллельные инстансы) | SKIP LOCKED — строка достаётся одному инстансу |
Гонка worker ↔ book-slot на границе StartAt | Арбитраж построчной блокировкой/версией (INV-SLT-5): если истечение выиграло — бронирование получает CAL_SLOT_NOT_AVAILABLE; если бронирование — слот стал Booked и вне выборки worker-а |
| Повторяемая ошибка (инфраструктура) | Откат батча, блокировки сняты; следующий цикл повторяет |
| Poison item | Исключается из батча, ERROR-лог с SlotId; не блокирует батч; при систематическом повторе — алерт по errors_total |
Наблюдаемость
| Тип | Правило |
|---|
| Logs | cycle started (runId, AsOf); batch selected (count); cycle completed (ExpiredCount, DurationMs, SkippedLocked); item failed (SlotId, exception). Per-slot логов успеха нет (массовая гигиена); PII нет |
| Metrics | calendar_slot_expiration_cycle_duration_seconds (histogram); calendar_slot_expiration_items_total; calendar_slot_expiration_backlog (gauge — Available со StartAt < now, ещё не обработанные); calendar_slot_expiration_skip_locked_total; calendar_slot_expiration_errors_total |
| Traces | correlationId = job run id; tenantId (per item, из слота) |
| Dashboard | Grafana calendar-overview (панель Background jobs) |
| Alerts | нет успешного цикла > 30 мин (liveness, мягкий порог tier-3); backlog > 5000 устойчиво (worker отстаёт — выборки деградируют); errors_total rate > 0 устойчиво |
Связанный Runbook
| Сценарий | Runbook |
|---|
| Worker завис / backlog растёт | ../runbooks/slot-expiration.md (будущий путь по конвенции) |
| Poison-слот не истекает | ../runbooks/slot-expiration.md |
Тесты
| Тест | Ожидание |
|---|
| Empty batch | Нет Available со StartAt ≤ now → no-op success, мутаций нет |
| Истечение | Available при now ≥ StartAt → Expired, UpdatedAt = now, версия +1; повторный проход ничего не меняет |
Booked не трогается | Booked-слот с прошедшим StartAt не выбирается (его судьба — каскад записи) |
| Гонка с бронированием | book-slot закоммичен до срабатывания → слот Booked, вне выборки; истечение выиграло → бронирование получает CAL_SLOT_NOT_AVAILABLE |
| Partial failure | Poison-строка исключена и залогирована, остальной батч закоммичен |
| Duplicate / параллельные инстансы | SKIP LOCKED — каждая строка обработана один раз |
| Cancellation | остановка во время цикла → батч докоммичен или откачен целиком, блокировки сняты, graceful stop |
Связанные документы