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

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, «Доменные и интеграционные события»).

ПолеЗначение
OwnerPIN Platform (backend)
Статус документаdraft
Область действияworker slot-expiration, слоты доступности контекста calendar; исходящих топиков нет
Assumptions / ограничениярост числа слотов ограничен лимитом батча публикации (≤ 500) и горизонтом (ADR-0064); точность перехода не влияет на корректность бронирования (guard-ы на операции)
ЗависимостиPostgreSQL (слоты)

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

ПолеЗначение
Сервисcalendar
Тип jobRecurring (polling-worker)
Triggerцикл 5 мин (без внешнего триггера/топика)
Критичностьtier-3 maintenance (отставание не ломает ни данные, ни бронирование — только гигиена выборок)

Расписание и Locking

АспектПравило
Scheduleкаждые 5 мин (UTC-часы) — точность секунд не нужна (guard-ы бронирования не зависят от worker-а); следующий тик не стартует, пока идёт предыдущий цикл
Distributed lockнет глобального lock (ADR-0056 — без Redis); параллельные инстансы координируются построчной блокировкой SKIP LOCKED — каждая строка достаётся ровно одному инстансу
Batch size500 строк/цикл (SlotExpirationOptions.BatchSize); backlog добирается следующими циклами (order by StartAt asc)
Max durationцель < 30 с/цикл; при превышении — тик пропускается, добор в следующем цикле
Cancellationостановка хоста проходит в каждый I/O; текущий batch докоммичивается или откатывается целиком, блокировки освобождаются

Входные параметры цикла

ПолеТипОбязательностьИсточникОписание
AsOfDateTimeOffsetДасистемные часыМомент цикла для сравнения со StartAt.
BatchSizeintДаSlotExpirationOptions.BatchSize (default 500)Верхняя граница строк за цикл.
TenantIdНетне применяетсяCross-tenant scan: платформенный system-worker без tenant-фильтра (ADR-0052 п.3) — слоты всех агентств и консультантов ПИН.

Алгоритм

  1. Контекст. Actor = system (cross-tenant разрешён явно); correlationId = job run id; AsOf = системные часы. Бизнес-идемпотентность обеспечивается предикатом статуса («истекают только Available»); отдельный idempotency-ключ не нужен.
  2. Проверка входа. Внешнего ввода нет; допустимость состояния встроена в предикат выборки: Status = Available && StartAt ≤ AsOf (индекс ix_availability_slots_status_start). Booked-слоты не выбираются — их судьбу решают каскады записи (appointment.md, INV-SLT-4); Expired/Archived терминальны для worker-а.
  3. Что читаем. Выборка под построчной блокировкой SKIP LOCKED, порядок по StartAt asc, лимит BatchSize. SKIP LOCKED пропускает строки, залоченные другим инстансом или in-flight book-slot на границе StartAt — без ожидания.
  4. Бизнес-правила и переходы. Для каждого слота — переход Available → Expired (INV-SLT-5, идемпотентный): StatusExpired, UpdatedAtAsOf, версия +1. Слот скрывается из витрины и рабочих выборок; агент может добить его в Archived вручную (уборка, availability-slot.md, stateDiagram).
  5. Что меняется. Обновляются Status/UpdatedAt/Version слотов batch-а (уже заблокированных). Напоминаний и календарных событий у невостребованного слота нет — каскадов нет.
  6. События. Не публикуются: занятие/освобождение слотов видно downstream только через события записи pin.calendar.appointment-*; истечение невостребованного слота — внутреннее дело контекста.
  7. Согласованность. Граница — на батч: все переходы батча фиксируются атомарно; блокировки держатся до commit. Poison-строка исключается из батча, логируется и остаётся на следующий цикл; остальной батч коммитится (continue, не stop).
  8. Результат. { 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

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

ТипПравило
Logscycle started (runId, AsOf); batch selected (count); cycle completed (ExpiredCount, DurationMs, SkippedLocked); item failed (SlotId, exception). Per-slot логов успеха нет (массовая гигиена); PII нет
Metricscalendar_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
TracescorrelationId = job run id; tenantId (per item, из слота)
DashboardGrafana 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 ≥ StartAtExpired, UpdatedAt = now, версия +1; повторный проход ничего не меняет
Booked не трогаетсяBooked-слот с прошедшим StartAt не выбирается (его судьба — каскад записи)
Гонка с бронированиемbook-slot закоммичен до срабатывания → слот Booked, вне выборки; истечение выиграло → бронирование получает CAL_SLOT_NOT_AVAILABLE
Partial failurePoison-строка исключена и залогирована, остальной батч закоммичен
Duplicate / параллельные инстансыSKIP LOCKED — каждая строка обработана один раз
Cancellationостановка во время цикла → батч докоммичен или откачен целиком, блокировки сняты, graceful stop

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