Background Job: AppointmentTimeoutJob
Назначение
AppointmentTimeoutJob — фоновый polling-worker контекста calendar (appointment-timeout), который
истекает записи, по которым агент не принял решение в срок: Pending-записи с наступившим
PendingExpiresAt переводятся в Expired (appointment.md, матрица
переходов), с полным каскадом в том же изменении — слот освобождается (Booked → Available, если ещё
пригоден для повторного бронирования, иначе Booked → Expired), порождённое календарное событие
отменяется (→ Cancelled), его Pending-напоминания переводятся в Skipped, публикуется
pin.calendar.appointment-cancelled.v1 (Reason = Expired). Инвестор не ждёт вечно: слот возвращается
на витрину, CRM пишет ленту лида, notifications уведомляет стороны. Механизм — polling без Redis/TTL
(ADR-0056): выборка кандидатов
под построчной блокировкой SKIP LOCKED, переход + каскад + outbox атомарно.
| Поле | Значение |
|---|
| Owner | PIN Platform (backend) |
| Статус документа | draft |
| Область действия | worker appointment-timeout, записи контекста calendar, топики pin.calendar.appointment-cancelled.v1, pin.calendar.event-status-changed.v1 |
| Assumptions / ограничения | PendingExpiresAt = min(CreatedAt + 24 ч, StartAt − MinLeadTimeMinutes слота) — вычислен при создании записи (дом — appointment.md); Pending-записей одновременно немного (auto-confirm-слоты фазу Pending минуют) |
| Зависимости | PostgreSQL (записи, слоты, события, напоминания, outbox), Kafka (outbox-publisher) |
Классификация
| Поле | Значение |
|---|
| Сервис | calendar |
| Тип job | Recurring (polling-worker) |
| Trigger | цикл 60 с (без внешнего триггера/топика) |
| Критичность | tier-2 (зависшие Pending блокируют слоты и запись других инвесторов, но не данные сделок) |
Расписание и Locking
| Аспект | Правило |
|---|
| Schedule | каждые 60 с (UTC-часы); следующий тик не стартует, пока идёт предыдущий цикл (без наложений) |
| Distributed lock | нет глобального lock (ADR-0056); параллельные инстансы координируются построчной блокировкой SKIP LOCKED — каждая запись достаётся ровно одному инстансу |
| Batch size | 200 записей/цикл (AppointmentTimeoutOptions.BatchSize); backlog добирается следующими циклами (order by PendingExpiresAt asc) |
| Max duration | цель < 30 с/цикл; при превышении — тик пропускается, добор в следующем цикле |
| Cancellation | остановка хоста проходит в каждый I/O; текущий batch докоммичивается или откатывается целиком, блокировки освобождаются |
Входные параметры цикла
| Поле | Тип | Обязательность | Источник | Описание |
|---|
AsOf | DateTimeOffset | Да | системные часы | Момент цикла для сравнения с PendingExpiresAt и правила освобождения слота. |
BatchSize | int | Да | AppointmentTimeoutOptions.BatchSize (default 200) | Верхняя граница записей за цикл. |
TenantId | — | Нет | не применяется | Cross-tenant scan: платформенный system-worker без tenant-фильтра (ADR-0052 п.3) — записи всех агентств и консультантов ПИН. |
Алгоритм
- Контекст. Actor = system (cross-tenant разрешён явно);
correlationId = job run id; AsOf =
системные часы. Бизнес-идемпотентность обеспечивается предикатом статуса («истекают только
Pending»); отдельный idempotency-ключ не нужен.
- Проверка входа. Внешнего ввода нет; допустимость состояния встроена в предикат выборки:
Status = Pending && PendingExpiresAt < AsOf (partial-индекс ix_appointments_pending_expires_at);
терминальные и Confirmed записи не выбираются. Записи auto-confirm-слотов в выборку не попадают по
построению (PendingExpiresAt = null при auto-confirm).
- Что читаем. Выборка под построчной блокировкой
SKIP LOCKED, порядок по PendingExpiresAt asc,
лимит BatchSize; вместе с записью читаются слот (availability-slot.md —
StartAt, MinLeadTimeMinutes для правила освобождения) и порождённое календарное событие
(CalendarEventId). SKIP LOCKED пропускает записи, залоченные другим инстансом или in-flight
операцией (confirm/decline/cancel агента, отмена инвестором) — без ожидания.
- Бизнес-правила и переходы. Для каждой записи — переход
Pending → Expired (единственный канал
выставления Expired, INV-APT-6) с каскадом по таблице «переход записи → эффект на слот и событие»
(appointment.md):
- запись:
Status = Expired, UpdatedAt = AsOf (CancelledBy не заполняется — таймаут не отмена,
отдельный статус);
- слот:
Booked → Available, если AsOf ≤ Slot.StartAt − MinLeadTimeMinutes (слот ещё пригоден для
повторной продажи и возвращается на витрину), иначе Booked → Expired (INV-SLT-5);
- событие (
Origin = Appointment): → Cancelled, CancelReason = «Запись не подтверждена агентом в
срок» (санкционированный каскад, INV-CAL-3);
- напоминания события: все
Pending → Skipped (event-reminder.md,
правило 3).
- Что меняется. Обновляются запись, слот, событие и reminder-строки каждого элемента batch-а (все
уже заблокированы построчно); версии агрегатов +1.
- События. По каждой истёкшей записи в транзакционный outbox пишутся:
AppointmentCancelledEvent (ContractVersion = 1, topic pin.calendar.appointment-cancelled.v1,
key = AppointmentId, Reason = Expired — дом контракта appointment.md,
раздел «Доменные и интеграционные события»; потребители crm — лента лида, notifications) и
штатное CalendarEventStatusChangedEvent (topic pin.calendar.event-status-changed.v1,
Status = Cancelled — потребитель notifications).
- Согласованность. Граница — на элемент батча (запись + слот + событие + напоминания + outbox
фиксируются атомарно per запись; независимые записи не связаны общей судьбой). Poison-элемент
(неожиданное состояние каскада) откатывается, логируется и остаётся на следующий цикл; остальной
батч продолжается (
continue, не stop).
- Результат.
{ ExpiredCount, SlotsReleasedCount, SlotsExpiredCount, SkippedLockedCount, DurationMs } — для метрик/логов. Инфраструктурная ошибка → откат текущего элемента, освобождение
блокировок; ретрай — следующим циклом (кандидаты ещё Pending → выберутся снова, идемпотентно).
Граница транзакции
| Элемент | Правило |
|---|
| Единица согласованности | per item — запись + слот + событие + напоминания + outbox одной записи атомарно (каскад неделим, INV-APT-6); блокировки живут до commit элемента |
| Сохраняемое состояние | переходы записи/слота/события, Skipped-напоминания, записи outbox (appointment-cancelled, event-status-changed) |
| External calls | Kafka-публикация — после commit, outbox-publisher-ом (at-least-once); внутри цикла внешних синхронных вызовов нет |
| Частичная ошибка | continue — poison-элемент откатывается и логируется, остальные записи батча обрабатываются; инфраструктурный сбой — откат текущего элемента, ретрай следующим циклом |
Постусловия и цепочка следствий
Предусловия цикла. Существуют Pending-записи с наступившим PendingExpiresAt; crm и
notifications подписаны на pin.calendar.appointment-cancelled.v1.
Постусловия (после commit элемента). Запись терминальна (Expired); слот Available (виден на
витрине) либо Expired; событие Cancelled с причиной; Pending-напоминания Skipped; по записи —
ровно одна пара записей outbox; запись больше не выбирается предикатом.
Цепочка следствий (асинхронно, eventual consistency). appointment-cancelled (Reason = Expired)
разветвляется: crm пишет строку ленты лида («запись истекла без решения агента»,
../../crm/consumers/calendar-appointments.md — путь по конвенции); notifications уведомляет инвестора
(«агент не подтвердил запись, выберите другой слот») и агента. Освобождённый слот виден на витрине сразу
после commit (данные calendar, синхронно).
Что видит пользователь в промежутке. Между PendingExpiresAt и срабатыванием worker-а (до ~1 цикла)
запись ещё выглядит Pending — агент, успевший подтвердить в этом окне, выигрывает гонку штатно
(см. ниже).
Идемпотентность и retry
| Сценарий | Правило |
|---|
| Job restart / повторный цикл | Checkpoint не нужен: выборка отбирает только Pending, закоммиченный переход терминален и невыбираем → повтор безопасен (idempotent by state); ровно одно appointment-cancelled (Reason = Expired) на запись |
| Duplicate item (параллельные инстансы) | SKIP LOCKED — запись достаётся одному инстансу; двойного события нет |
Гонка worker ↔ агент (confirm/decline) ↔ инвестор (cancel) | Запись под in-flight операцией залочена → worker пропускает её в этом цикле; если операция закоммитила между чтением и записью worker-а — конфликт версий (Version), проигравший перечитывает: запись уже не Pending → в выборку не попадает. Подтверждение, успевшее до commit worker-а, выигрывает — запись остаётся Confirmed |
| Гонка «освобождение слота ↔ новое бронирование» | Освобождение и book-slot конкурируют за строку слота — арбитраж построчной блокировкой/версией (INV-SLT-3); слот становится доступным для брони только после commit |
| Повторяемая ошибка (инфраструктура) | Откат элемента, блокировки сняты; следующий цикл повторяет |
| Poison item | Откатывается и логируется (ERROR с AppointmentId); не блокирует батч; при систематическом повторе — алерт по errors_total, разбор по runbook |
Наблюдаемость
| Тип | Правило |
|---|
| Logs | cycle started (runId, AsOf); batch selected (count); appointment expired (AppointmentId, SlotId, CalendarEventId, TenantId, исход слота released|expired); cycle completed (counts, DurationMs, SkippedLocked); item failed (AppointmentId, exception). PII нет — только идентификаторы |
| Metrics | calendar_appointment_timeout_cycle_duration_seconds (histogram); calendar_appointment_timeout_items_total{slot_outcome=released|expired}; calendar_appointment_timeout_backlog (gauge — Pending с PendingExpiresAt < now, ещё не обработанные); calendar_appointment_timeout_skip_locked_total; calendar_appointment_timeout_errors_total; outbox lag |
| Traces | correlationId = job run id; tenantId (per item, из записи); job run id — на span цикла и outbox-записей |
| Dashboard | Grafana calendar-overview (панель Background jobs) |
| Alerts | нет успешного цикла > 5 мин (liveness); backlog > 100 в течение 5 мин (рост зависших Pending-записей, README «Alerts»); errors_total rate > 0 устойчиво; outbox backlog |
Связанный Runbook
| Сценарий | Runbook |
|---|
| Worker завис / backlog растёт | ../runbooks/appointment-timeout.md (будущий путь по конвенции) |
| Poison-запись не истекает | ../runbooks/appointment-timeout.md |
Лаг outbox appointment-cancelled | ../runbooks/outbox-backlog.md (будущий путь) |
Тесты
| Тест | Ожидание |
|---|
| Empty batch | Нет Pending с истёкшим PendingExpiresAt → no-op success, событий/мутаций нет |
| Таймаут, слот пригоден | Pending при now > PendingExpiresAt, now ≤ Slot.StartAt − MinLeadTime → запись Expired, слот Available, событие Cancelled, напоминания Skipped, appointment-cancelled (Reason=Expired) + event-status-changed в outbox |
| Таймаут, lead time прошёл | То же при now > Slot.StartAt − MinLeadTime → слот Expired (на витрину не возвращается) |
| Auto-confirm вне выборки | Записи с PendingExpiresAt = null никогда не выбираются |
| Partial failure | Poison-элемент откачен и залогирован, остальные записи батча закоммичены |
| Duplicate / параллельные инстансы | SKIP LOCKED — каждая запись обработана один раз, ровно одно событие на запись |
| Гонка с подтверждением агента | confirm-appointment закоммичен до срабатывания → запись Confirmed, worker её не истекает (вне выборки либо конфликт версий с перечиткой) |
| Cancellation | остановка во время цикла → текущий элемент докоммичен или откачен целиком, блокировки сняты, graceful stop |
| SLO точности | Запись с PendingExpiresAt = t истекает не позже t + 60 с + DurationCycle; освобождённый слот виден на витрине сразу после commit |
Обратные ссылки
Автоссылки: где используется этот документ.
Связанные документы