Background Job: BookingExpirationJob
Назначение
BookingExpirationJob — фоновый polling-worker контекста crm (booking-expiration), который переводит
активные брони с истёкшим таймером в терминальное истекшее состояние и публикует
BookingExpiredEvent (INV-BKG-5 в booking.md).
Обрабатывает обе фазы одного агрегата BookingEntity: истечение резерва
(ReserveExpiresAt, статусы Reserved/AwaitingPayment → ReserveExpired) и истечение брони
(BookingExpiresAt, Booked → BookingExpired). Таймеры — параметры программы бронирования
(booking-program.md). Смену статуса объекта недвижимости в шахматке делает
консюмер chessboard по опубликованному событию — worker чужие таблицы не пишет
(service-boundaries).
Polling без Redis/TTL (ADR-0056): выборка кандидатов под построчной блокировкой SKIP LOCKED, переход +
outbox атомарно в одной записи.
| Поле | Значение |
|---|---|
| Owner | PIN Platform (CRM squad) |
| Статус документа | draft |
| Область действия | worker booking-expiration, брони контекста crm, топик pin.crm.booking-expired.v1 |
| Assumptions / ограничения | целевая нагрузка 1–10k активных броней (ADR-0056); эквайринг отложен, истекают и оплаченные, и неоплаченные брони по таймеру |
| Зависимости | PostgreSQL (брони, outbox), Kafka (outbox-publisher) |
Классификация
| Поле | Значение |
|---|---|
| Сервис | crm |
| Тип job | Recurring (polling-worker) |
| Trigger | цикл 1 мин (без внешнего триггера/топика) |
| Критичность | tier-1 mission-critical (освобождение объекта недвижимости — центр воронки денег) |
Расписание и Locking
| Аспект | Правило |
|---|---|
| Schedule | каждые 60 с (UTC-часы); следующий тик не стартует, пока идёт предыдущий цикл (без наложений) |
| Distributed lock | нет глобального lock (ADR-0056 — без Redis); параллельные инстансы координируются построчной блокировкой SKIP LOCKED в выборке — каждая строка достаётся ровно одному инстансу |
| Batch size | 200 строк/цикл на фазу (BookingExpirationOptions.BatchSize); backlog добирается следующими циклами (order by дедлайн asc) |
| Max duration | цель < 30 с/цикл; при превышении — тик пропускается, добор в следующем цикле |
| Cancellation | остановка хоста проходит в каждый I/O; текущий batch докоммичивается или откатывается целиком, блокировки освобождаются |
Входные параметры цикла
| Поле | Тип | Обязательность | Источник | Описание |
|---|---|---|---|---|
AsOf | DateTimeOffset | Да | системные часы | Момент цикла для сравнения с ReserveExpiresAt/BookingExpiresAt. |
BatchSize | int | Да | BookingExpirationOptions.BatchSize (default 200) | Верхняя граница строк за фазу за цикл. |
TenantId | — | Нет | не применяется | Cross-tenant scan: платформенный system-worker без tenant-фильтра (ADR-0052 п.3), обрабатывает брони всех застройщиков. |
Алгоритм
Каждый цикл формирует отсечку AsOf и обрабатывает обе фазы:
- Контекст. Actor = system (scope-фильтры отключены, cross-tenant разрешён явно);
correlationId= job run id для сквозной трассировки цикла;AsOf= системные часы. Источник — расписание, не Kafka; бизнес- идемпотентность обеспечивается предикатом статуса (шаг 2, «истекают только активные»), отдельный idempotency-ключ не нужен. - Проверка входа. Синтаксис/диапазоны — не применяется (внешнего ввода нет). Существование
сущностей по id — не применяется (батч-скан). Допустимость состояния — встроена в предикат
выборки: только
Status ∈ {Reserved, AwaitingPayment}приAsOf > ReserveExpiresAt(фазаReserve) иStatus = BookedприAsOf > BookingExpiresAt(фазаBooking); терминальные статусы не выбираются (INV-BKG-8). Scope — cross-tenant разрешён явно (system-worker). - Что читаем. Две выборки на цикл, каждая под построчной блокировкой
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) — без ожидания.
- фаза
- Бизнес-правила и переходы. Для каждой брони — доменный переход
Expire(AsOf): определяет фазу по текущемуStatusи применяетReserved/AwaitingPayment → ReserveExpired(Phase = Reserve) либоBooked → BookingExpired(Phase = Booking), guard INV-BKG-5/INV-BKG-8. Маппинг:Status← целевой терминальный;ExpiredAt←AsOf;UpdatedAt←AsOf; версия +1. Переход поднимает доменное событиеBookingExpired(подписчик — таймлайн сделки deal.md). - Что меняется. Обновляются
Status/ExpiredAt/UpdatedAt/версия броней batch-а (уже заблокированных). Порядок в батче — по дедлайну. - Событие. Доменное
BookingExpired(in-memory) диспетчеризуется при commit. ИнтеграционноеBookingExpiredEvent(ContractVersion = 1, topicpin.crm.booking-expired.v1,AggregateType = "Booking") пишется в транзакционный outbox. Payload из полей агрегата:BookingId,TenantId,DealId,PropertyObjectId,Phase,ExpiredAt— дом контракта booking-expired.md (PII не несёт). - Согласованность. Граница — на батч: все переходы батча + записи outbox фиксируются атомарно в
одной записи; блокировки строк держатся до commit. Poison-строка (доменный guard кинул при неожиданном
состоянии — не должно случаться при корректном предикате) исключается из батча, логируется и остаётся на
следующий цикл; остальной батч коммитится (
continue, неstop). - Результат.
{ ReserveExpiredCount, BookingExpiredCount, SkippedLockedCount, DurationMs }— для метрик/логов, не HTTP-ответ. Инфраструктурная ошибка → откат записи, освобождение блокировок; ретрай — следующим циклом (те же строки ещё активны → выберутся снова, идемпотентно).
Граница транзакции
| Элемент | Правило |
|---|---|
| Единица согласованности | per batch — на весь batch фазы; блокировки строк живут до commit |
| Сохраняемое состояние | переходы броней (Status/ExpiredAt/UpdatedAt/Version) + audit + записи outbox (BookingExpiredEvent) |
| External calls | Kafka-публикация — после 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) и разветвляется:
- Консюмер
chessboardосвобождает объект недвижимости (статус →Свободен) — целевое окно < 1 мин от commit (SLO точности, раздел «Тесты»). - Таймлайн сделки 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 |
Наблюдаемость
| Тип | Правило |
|---|---|
| Logs | cycle started (runId, AsOf); batch selected (phase, count); booking expired (BookingId, DealId, Phase, TenantId); cycle completed (counts, DurationMs, SkippedLocked); item failed (BookingId, exception) |
| Metrics | crm_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 |
| Traces | correlationId = job run id; tenantId (per item, из брони); job run id — на span цикла и outbox-записи |
| Dashboard | Grafana 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 > ReserveExpiresAt → ReserveExpired, ExpiredAt=now, событие Phase=Reserve в outbox |
| Booking истёк | Booked при now > BookingExpiresAt → BookingExpired, событие Phase=Booking |
| Partial failure | Poison-строка исключена и залогирована, остальной батч закоммичен; poison — на следующий цикл |
| Duplicate / параллельные инстансы | SKIP LOCKED — каждая строка обработана один раз, ровно одно событие на бронь |
| Гонка с менеджером | Extend/ConfirmBooking до срабатывания → строка вне выборки либо конфликт версий → worker не истекает уже нетерминальную/продлённую бронь |
| Cancellation | остановка во время цикла → батч докоммичен или откачен целиком, блокировки сняты, graceful stop |
| SLO точности | Бронь с дедлайном t истекает не позже t + 60 с + DurationCycle; событие в outbox сразу, объект недвижимости свободен в chessboard < 1 мин |
Обратные ссылки
Автоссылки: где используется этот документ.
- Домен (CQRS) (1): Entity: EventReminderEntity
- Use Cases (2): UC-BKG-003. Истечение резерва/брони и продление, UC-CAL-008. Напоминания о событиях календаря
- События (1): Event Contract: pin.calendar.reminder-due (ReminderDueEvent)
- Фоновые задачи (6): Background Job: AppointmentTimeoutJob, Background Job: CallTimeoutReaperJob, Background Job: ConferenceRoomReaperJob, Background Job: property-object-hold-expiration (страховка от потерянных release-событий CRM), Background Job: ReminderDispatchJob, Background Job: SlotExpirationJob
- Саги (1): Saga «Booking» (Резерв → Бронь → передача в продажу)
- adr (1): ADR-0064 — контекст calendar (календарь и запись на показ/консультацию)
- index.md (1): Calendar Service
Связанные документы
- Агрегат и переходы (INV-BKG-5, stateDiagram): booking.md; таймеры — booking-program.md; родительский процесс — deal.md.
- Публикуемый контракт: booking-expired.md; заявка worker-а — README «Background jobs / Runtime».
- ADR: 0056 simplicity-first (polling без Redis, построчная блокировка, идемпотентность), 0052 tenancy (system cross-tenant scope).
- SDK: 05 repository-pipeline, 11 distributed-locking.