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

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 атомарно.

ПолеЗначение
OwnerPIN 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
Тип jobRecurring (polling-worker)
Triggerцикл 60 с (без внешнего триггера/топика)
Критичностьtier-2 (зависшие Pending блокируют слоты и запись других инвесторов, но не данные сделок)

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

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

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

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

Алгоритм

  1. Контекст. Actor = system (cross-tenant разрешён явно); correlationId = job run id; AsOf = системные часы. Бизнес-идемпотентность обеспечивается предикатом статуса («истекают только Pending»); отдельный idempotency-ключ не нужен.
  2. Проверка входа. Внешнего ввода нет; допустимость состояния встроена в предикат выборки: Status = Pending && PendingExpiresAt < AsOf (partial-индекс ix_appointments_pending_expires_at); терминальные и Confirmed записи не выбираются. Записи auto-confirm-слотов в выборку не попадают по построению (PendingExpiresAt = null при auto-confirm).
  3. Что читаем. Выборка под построчной блокировкой SKIP LOCKED, порядок по PendingExpiresAt asc, лимит BatchSize; вместе с записью читаются слот (availability-slot.mdStartAt, MinLeadTimeMinutes для правила освобождения) и порождённое календарное событие (CalendarEventId). SKIP LOCKED пропускает записи, залоченные другим инстансом или in-flight операцией (confirm/decline/cancel агента, отмена инвестором) — без ожидания.
  4. Бизнес-правила и переходы. Для каждой записи — переход 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);
    • напоминания события: все PendingSkipped (event-reminder.md, правило 3).
  5. Что меняется. Обновляются запись, слот, событие и reminder-строки каждого элемента batch-а (все уже заблокированы построчно); версии агрегатов +1.
  6. События. По каждой истёкшей записи в транзакционный 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).
  7. Согласованность. Граница — на элемент батча (запись + слот + событие + напоминания + outbox фиксируются атомарно per запись; независимые записи не связаны общей судьбой). Poison-элемент (неожиданное состояние каскада) откатывается, логируется и остаётся на следующий цикл; остальной батч продолжается (continue, не stop).
  8. Результат. { ExpiredCount, SlotsReleasedCount, SlotsExpiredCount, SkippedLockedCount, DurationMs } — для метрик/логов. Инфраструктурная ошибка → откат текущего элемента, освобождение блокировок; ретрай — следующим циклом (кандидаты ещё Pending → выберутся снова, идемпотентно).

Граница транзакции

ЭлементПравило
Единица согласованностиper item — запись + слот + событие + напоминания + outbox одной записи атомарно (каскад неделим, INV-APT-6); блокировки живут до commit элемента
Сохраняемое состояниепереходы записи/слота/события, Skipped-напоминания, записи outbox (appointment-cancelled, event-status-changed)
External callsKafka-публикация — после 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

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

ТипПравило
Logscycle started (runId, AsOf); batch selected (count); appointment expired (AppointmentId, SlotId, CalendarEventId, TenantId, исход слота released|expired); cycle completed (counts, DurationMs, SkippedLocked); item failed (AppointmentId, exception). PII нет — только идентификаторы
Metricscalendar_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
TracescorrelationId = job run id; tenantId (per item, из записи); job run id — на span цикла и outbox-записей
DashboardGrafana 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 failurePoison-элемент откачен и залогирован, остальные записи батча закоммичены
Duplicate / параллельные инстансыSKIP LOCKED — каждая запись обработана один раз, ровно одно событие на запись
Гонка с подтверждением агентаconfirm-appointment закоммичен до срабатывания → запись Confirmed, worker её не истекает (вне выборки либо конфликт версий с перечиткой)
Cancellationостановка во время цикла → текущий элемент докоммичен или откачен целиком, блокировки сняты, graceful stop
SLO точностиЗапись с PendingExpiresAt = t истекает не позже t + 60 с + DurationCycle; освобождённый слот виден на витрине сразу после commit

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

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

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