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

Background Job: BookingExpirationJob

Назначение

BookingExpirationJob — фоновый polling-worker контекста crm (booking-expiration), который переводит активные брони с истёкшим таймером в терминальное истекшее состояние и публикует BookingExpiredEvent (INV-BKG-5 в booking.md). Обрабатывает обе фазы одного агрегата BookingEntity: истечение резерва (ReserveExpiresAt, статусы Reserved/AwaitingPaymentReserveExpired) и истечение брони (BookingExpiresAt, BookedBookingExpired). Таймеры — параметры программы бронирования (booking-program.md). Смену статуса объекта недвижимости в шахматке делает консюмер chessboard по опубликованному событию — worker чужие таблицы не пишет (service-boundaries).

Механизм

Polling без Redis/TTL (ADR-0056): выборка кандидатов под построчной блокировкой SKIP LOCKED, переход + outbox атомарно в одной записи.

ПолеЗначение
OwnerPIN Platform (CRM squad)
Статус документаdraft
Область действияworker booking-expiration, брони контекста crm, топик pin.crm.booking-expired.v1
Assumptions / ограниченияцелевая нагрузка 1–10k активных броней (ADR-0056); эквайринг отложен, истекают и оплаченные, и неоплаченные брони по таймеру
ЗависимостиPostgreSQL (брони, outbox), Kafka (outbox-publisher)

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

ПолеЗначение
Сервисcrm
Тип jobRecurring (polling-worker)
Triggerцикл 1 мин (без внешнего триггера/топика)
Критичностьtier-1 mission-critical (освобождение объекта недвижимости — центр воронки денег)

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

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

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

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

Алгоритм

Каждый цикл формирует отсечку AsOf и обрабатывает обе фазы:

  1. Контекст. Actor = system (scope-фильтры отключены, cross-tenant разрешён явно); correlationId = job run id для сквозной трассировки цикла; AsOf = системные часы. Источник — расписание, не Kafka; бизнес- идемпотентность обеспечивается предикатом статуса (шаг 2, «истекают только активные»), отдельный idempotency-ключ не нужен.
  2. Проверка входа. Синтаксис/диапазоны — не применяется (внешнего ввода нет). Существование сущностей по id — не применяется (батч-скан). Допустимость состояния — встроена в предикат выборки: только Status ∈ {Reserved, AwaitingPayment} при AsOf > ReserveExpiresAt (фаза Reserve) и Status = Booked при AsOf > BookingExpiresAt (фаза Booking); терминальные статусы не выбираются (INV-BKG-8). Scope — cross-tenant разрешён явно (system-worker).
  3. Что читаем. Две выборки на цикл, каждая под построчной блокировкой SKIP LOCKED, порядок по дедлайну asc (сначала самые старые), лимит BatchSize:
    • фаза Reserve: Status ∈ {Reserved, AwaitingPayment} && ReserveExpiresAt < AsOf (partial-индекс ix_bookings_reserve_expires_at);
    • фаза Booking: Status == Booked && BookingExpiresAt < AsOf (partial-индекс ix_bookings_booking_expires_at). Блокировка SKIP LOCKED пропускает строки, залоченные другим инстансом или in-flight командой менеджера (ConfirmBooking/Extend/Cancel) — без ожидания.
  4. Бизнес-правила и переходы. Для каждой брони — доменный переход Expire(AsOf): определяет фазу по текущему Status и применяет Reserved/AwaitingPayment → ReserveExpired (Phase = Reserve) либо Booked → BookingExpired (Phase = Booking), guard INV-BKG-5/INV-BKG-8. Маппинг: Status ← целевой терминальный; ExpiredAtAsOf; UpdatedAtAsOf; версия +1. Переход поднимает доменное событие BookingExpired (подписчик — таймлайн сделки deal.md).
  5. Что меняется. Обновляются Status/ExpiredAt/UpdatedAt/версия броней batch-а (уже заблокированных). Порядок в батче — по дедлайну.
  6. Событие. Доменное BookingExpired (in-memory) диспетчеризуется при commit. Интеграционное BookingExpiredEvent (ContractVersion = 1, topic pin.crm.booking-expired.v1, AggregateType = "Booking") пишется в транзакционный outbox. Payload из полей агрегата: BookingId, TenantId, DealId, PropertyObjectId, Phase, ExpiredAt — дом контракта booking-expired.md (PII не несёт).
  7. Согласованность. Граница — на батч: все переходы батча + записи outbox фиксируются атомарно в одной записи; блокировки строк держатся до commit. Poison-строка (доменный guard кинул при неожиданном состоянии — не должно случаться при корректном предикате) исключается из батча, логируется и остаётся на следующий цикл; остальной батч коммитится (continue, не stop).
  8. Результат. { ReserveExpiredCount, BookingExpiredCount, SkippedLockedCount, DurationMs } — для метрик/логов, не HTTP-ответ. Инфраструктурная ошибка → откат записи, освобождение блокировок; ретрай — следующим циклом (те же строки ещё активны → выберутся снова, идемпотентно).

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

ЭлементПравило
Единица согласованностиper batch — на весь batch фазы; блокировки строк живут до commit
Сохраняемое состояниепереходы броней (Status/ExpiredAt/UpdatedAt/Version) + audit + записи outbox (BookingExpiredEvent)
External callsKafka-публикация — после commit, outbox-publisher-ом (at-least-once); внутри цикла внешних синхронных вызовов нет
Частичная ошибкаcontinue — poison-строка исключается из батча и логируется, остальной батч коммитится; инфраструктурный сбой — откат всего батча, ретрай следующим циклом

Постусловия и цепочка следствий

Предусловия цикла

Существуют активные брони с наступившими дедлайнами (ReserveExpiresAt/BookingExpiresAt); таймеры заданы программой бронирования; консюмер chessboard подписан на pin.crm.booking-expired.v1; таймлайн сделки реагирует на доменное BookingExpired.

Постусловия (после commit батча). Каждая обработанная бронь терминальна (ReserveExpired для фазы Reserve, BookingExpired для фазы Booking), ExpiredAt = AsOf, версия +1; по каждой — запись BookingExpiredEvent в outbox; строка больше не выбирается предикатом.

Цепочка следствий (асинхронно, eventual consistency). BookingExpiredEvent после commit публикуется outbox-publisher-ом (at-least-once) и разветвляется:

  1. Консюмер chessboard освобождает объект недвижимости (статус → Свободен) — целевое окно < 1 мин от commit (SLO точности, раздел «Тесты»).
  2. Таймлайн сделки deal.md получает карточку истечения резерва/брони (подписчик доменного BookingExpired).
Что видит пользователь в промежутке

Агент/инвестор видят бронь как истёкшую в CRM сразу после commit; в шахматке объект становится свободным только после обработки события консюмером chessboard — в окне между commit и этой обработкой другой инвестор ещё видит объект занятым (бронь на него отклоняется как CRM_PROPERTY_OBJECT_NOT_AVAILABLE), затем объект открывается к новой брони.

Идемпотентность и retry

СценарийПравило
Job restart / повторный циклCheckpoint не нужен: выборка отбирает только активные статусы, закоммиченный переход делает строку терминальной и невыбираемой → повтор безопасен (idempotent by state, INV-BKG-5)
Duplicate item (параллельные инстансы)Построчная блокировка SKIP LOCKED — строка достаётся одному инстансу, второй её пропускает; двойного события нет
Гонка worker ↔ менеджер (ConfirmBooking/Extend/Cancel)Строка под командой менеджера залочена/меняется → worker её пропускает (SKIP LOCKED) в этом цикле; если менеджер закоммитил между чтением и записью worker-а — конфликт версий при commit, проигравший ретраится с перечиткой (бронь уже терминальна/продлена → в выборку не попадёт)
Повторяемая ошибка (инфраструктура)Откат батча, блокировки сняты; следующий цикл (60 с) повторяет — те же кандидаты ещё активны
Poison itemИсключается из батча, ERROR-лог с BookingId; не блокирует батч; при систематическом повторе — алерт по errors_total, разбор по runbook

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

ТипПравило
Logscycle started (runId, AsOf); batch selected (phase, count); booking expired (BookingId, DealId, Phase, TenantId); cycle completed (counts, DurationMs, SkippedLocked); item failed (BookingId, exception)
Metricscrm_booking_expiration_cycle_duration_seconds (histogram); crm_booking_expiration_items_total{phase} (counter); crm_booking_expiration_backlog (gauge — активные брони с deadline < now, ещё не обработанные); crm_booking_expiration_skip_locked_total; crm_booking_expiration_errors_total; outbox lag
TracescorrelationId = job run id; tenantId (per item, из брони); job run id — на span цикла и outbox-записи
DashboardGrafana pin-crm (панель Background jobs)
Alertsнет успешного цикла > 5 мин (liveness worker-а); backlog > 100 в течение 5 мин (worker отстаёт/завис); errors_total rate > 0 устойчиво; outbox backlog > 1000 (README)

Связанный Runbook

СценарийRunbook
Worker завис / backlog растёт../runbooks/booking-expiration.md (будущий путь по конвенции)
Poison-бронь не истекает../runbooks/booking-expiration.md
Лаг outbox booking-expired../runbooks/outbox-backlog.md (будущий путь)

Тесты

ТестОжидание
Empty batchНет броней с истёкшим дедлайном → no-op success, событий/мутаций нет
Reserve истёкReserved/AwaitingPayment при now > ReserveExpiresAtReserveExpired, ExpiredAt=now, событие Phase=Reserve в outbox
Booking истёкBooked при now > BookingExpiresAtBookingExpired, событие Phase=Booking
Partial failurePoison-строка исключена и залогирована, остальной батч закоммичен; poison — на следующий цикл
Duplicate / параллельные инстансыSKIP LOCKED — каждая строка обработана один раз, ровно одно событие на бронь
Гонка с менеджеромExtend/ConfirmBooking до срабатывания → строка вне выборки либо конфликт версий → worker не истекает уже нетерминальную/продлённую бронь
Cancellationостановка во время цикла → батч докоммичен или откачен целиком, блокировки сняты, graceful stop
SLO точностиБронь с дедлайном t истекает не позже t + 60 с + DurationCycle; событие в outbox сразу, объект недвижимости свободен в chessboard < 1 мин

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

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

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