Background Job: property-object-hold-expiration (страховка от потерянных release-событий CRM)
Polling-воркер контекста chessboard: раз в минуту находит объекты недвижимости, «залипшие» в холде
(Status ∈ {Reserved, Booked}) с таймером (ReservedUntil/BookedUntil), истёкшим более чем на grace
(10 мин) назад, и возвращает их в Free. Срабатывание — сигнал, что штатное release-событие CRM не дошло
до шахматки; здесь описан только job-предохранитель, а не штатный путь освобождения.
Фоновый polling-worker контекста chessboard — страховка от потерянных release-событий CRM. Возвращает в
Free те объекты недвижимости, которые «залипли» в холде: Status ∈ {Reserved, Booked}, а таймер
(ReservedUntil/BookedUntil) истёк более чем на grace (10 мин) назад. Каждое срабатывание означает, что
release-событие CRM (pin.crm.booking-expired.v1 / pin.crm.booking-cancelled.v1 / pin.crm.deal-cancelled.v1;
маппинг — ../consumers/crm-property-object-status.md) не дошло до
шахматки (потеряно / consumer в DLQ / CRM-booking-expiration не сработал), поэтому worker пишет
warning-лог на каждый освобождённый объект недвижимости.
Разграничение ответственности с CRM
Source of truth таймеров и штатное освобождение — CRM: программа бронирования
booking-expiration публикует истечение, chessboard-consumer
crm-property-object-status переводит объект
недвижимости в Free. Сообщения «время брони истекло» в чат — CRM/messenger. Шахматка хранит
ReservedUntil/BookedUntil лишь для обратного отсчёта в UI, а этот job — только предохранитель на случай
сбоя штатного пути; grace 10 мин гарантирует, что worker не гонится с CRM и срабатывает лишь когда штатный
путь очевидно сломан. Смену статуса объекта недвижимости job делает в своей таблице
(chessboard.property-objects), чужих таблиц не пишет.
Механизм цикла — polling без Redis/внешнего планировщика (ADR-0056 п.3): выборка кандидатов под построчной
блокировкой с пропуском занятых, переход и исходящее событие фиксируются в одном согласованном изменении.
таймеры и штатный release — CRM (job лишь страховка); grace 10 мин — буфер против гонки с CRM; частота срабатываний в норме ≈ 0 (каждое = потерянный release CRM); объём активных холдов 1–10k (ADR-0056), партиционирования нет
каждые 60 с (UTC-часы); следующий тик не стартует, пока идёт предыдущий цикл (без наложений)
Distributed lock
нет глобального lock (ADR-0056 — без Redis); параллельные инстансы координируются построчной блокировкой с пропуском занятых (FOR UPDATE SKIP LOCKED) в выборке — каждый объект недвижимости достаётся ровно одному инстансу
Batch size
200 объектов недвижимости/цикл на фазу; backlog добирается следующими циклами (сортировка по таймеру asc)
Max duration
цель < 30 с/цикл (заметно меньше интервала); при превышении — тик пропускается, добор в следующем цикле
Cancellation
при остановке воркера текущий batch докоммичивается или откатывается целиком, локи освобождаются
Момент цикла для сравнения ReservedUntil/BookedUntil + HoldGrace.
HoldGrace
TimeSpan
Да
конфигурация (default 10 мин)
Буфер после истечения таймера, в течение которого штатный release CRM ещё ожидается — job не трогает объект недвижимости.
BatchSize
int
Да
конфигурация (default 200)
Верхняя граница объектов недвижимости за фазу за цикл.
TenantId
TenantId
Нет
не применяется
Cross-tenant scan: платформенный System-worker без tenant-фильтра (ADR-0052 п.3, авто-scope-фильтры отключены), обрабатывает объекты недвижимости всех застройщиков.
В chessboard.property-objects есть объекты в Status ∈ {Reserved, Booked} с истёкшим на HoldGrace
таймером (ReservedUntil/BookedUntil заполнены по INV-CHS-002) — иначе цикл no-op.
Штатный путь освобождения (CRM booking-expiration → release-топики → consumer crm-property-object-status)
не отработал в пределах timer + grace: каждое срабатывание job = потерянный release CRM.
Постусловия (после цикла).
Каждый освобождённый объект переведён в Free; ReservedUntil/BookedUntil/HoldDealId очищены
(INV-CHS-002), StatusSource = ExpirationJob, StatusChangedAt = AsOf, Version инкрементирован.
На каждый освобождённый объект: опубликован PropertyObjectStatusChangedKafkaEvent(… → Free, StatusSource = ExpirationJob), поднят ProjectAggregatesRecalcRequestedEvent (пересчёт
PropertyObjectsFreeCount), записан warning-лог «потерян release CRM».
Объекты вне выборки (в пределах [timer, timer + grace], залоченные другим инстансом/консюмером, уже Free)
— не тронуты; poison-объект исключён из батча и залогирован, остальной батч закоммичен.
Повторный прогон идемпотентен: освобождённый объект (Free, таймеры null) в выборку не попадает.
Каждый тик обрабатывает две фазы (Reserved, Booked) по порядку, каждая по своему partial-индексу таймера.
Контекст и доступ. Выполняет System-worker (scope-фильтры отключены, cross-tenant разрешён явно);
correlationId = id прогона для сквозной трассировки; AsOf = момент цикла. Источник — расписание, не Kafka;
бизнес-идемпотентность обеспечивается предикатом статуса (шаг 2), отдельный ключ не нужен.
Проверка. Внешнего ввода нет; существование по id не проверяется (батч-скан). Допустимость состояния
встроена в предикаты выборки: только Status = Reserved/Booked с истёкшим на HoldGrace таймером
(INV-CHS-002 гарантирует ReservedUntil != null ⇔ Reserved, BookedUntil != null ⇔ Booked);
Free/Sold/Hidden не выбираются. Scope — cross-tenant (System-worker).
Что читаем. Кандидаты под построчной блокировкой с пропуском занятых, две выборки на цикл:
фаза Reserved: Status = Reserved и ReservedUntil + HoldGrace < AsOf (partial-индекс
ix_property_objects_reserved_until where reserved_until is not null);
фаза Booked: Status = Booked и BookedUntil + HoldGrace < AsOf (partial-индекс
ix_property_objects_booked_until where booked_until is not null);
сортировка по таймеру asc (сначала самые старые холды), не более BatchSize строк. Блокировка держит
выбранные строки до коммита и пропускает объекты, залоченные другим инстансом или обрабатываемые
консюмером crm-property-object-status (запоздавший release/book), — без ожидания.
Бизнес-правила и переходы. Для каждого объекта выполняется Release с источником ExpirationJob
(property-object.md): guard INV-CHS-001 разрешает Reserved → Free и
Booked → Free для StatusSource = ExpirationJob; INV-CHS-003 (HoldOwnership) не применяется — job не
несёт DealId и не сверяется с HoldDealId (в этом и смысл страховки: release CRM потерян). Маппинг
поле-в-поле: Status ← Free; ReservedUntil ← null, BookedUntil ← null, HoldDealId ← null (INV-CHS-002
чистит таймеры и владельца холда); StatusSource ← ExpirationJob; StatusChangedAt ← AsOf; UpdatedAt ← AsOf;
Version += 1. Переход поднимает доменное событие PropertyObjectStatusChangedEvent (… → Free,
StatusSource = ExpirationJob).
Что меняется. Обновляются поля перехода объекта
(Status/ReservedUntil/BookedUntil/HoldDealId/StatusSource/StatusChangedAt/UpdatedAt/Version);
оптимистическая блокировка защищает от гонки с запоздавшим консюмером CRM (см. «Идемпотентность»).
Какие события фиксируются. Доменное PropertyObjectStatusChangedEvent при коммите поднимает
ProjectAggregatesRecalcRequestedEvent (Reserved/Booked → Free меняет Project.PropertyObjectsFreeCount)
— пересчёт делает debounce-джоб project-aggregates-recalc (30 с).
Интеграционное PropertyObjectStatusChangedKafkaEvent (ContractVersion = 1, топик
pin.chessboard.property-object-status-changed.v1, key/AggregateId = PropertyObjectId,
AggregateType = "PropertyObject") фиксируется транзакционно. Payload из полей объекта: PropertyObjectId,
TenantId, ProjectId, BuildingId, FromStatus (Reserved/Booked), ToStatus = Free,
StatusSource = ExpirationJob, ReservedUntil = null, BookedUntil = null, HoldDealId = null,
SoldAt = null, StatusChangedAt, Version — дом контракта
property-object-status-changed.md (потребители
catalog/favorites/selections/notifications; PII не несёт). Дополнительно — warning-лог на каждый
объект (потерян release CRM, см. «Наблюдаемость»).
Согласованность. Граница — на батч: все переходы батча + audit/versioning + исходящие события на
каждый объект фиксируются атомарно; построчные локи держатся до коммита и освобождаются им. Poison-объект
(guard кинул при неожиданном состоянии — не должно случаться при корректном предикате) исключается из батча,
логируется и остаётся на следующий цикл; остальной батч коммитится (continue, не stop).
Результат. Счётчики цикла (ReservedReleasedCount, BookedReleasedCount, SkippedLockedCount,
DurationMs) — для метрик/логов, не HTTP-ответ. Инфраструктурная ошибка → откат транзакции, освобождение
локов; ретрай — следующим циклом (те же объекты ещё в холде → выберутся снова, идемпотентно).
Checkpoint не нужен: выборка отбирает только Reserved/Booked с истёкшим на grace таймером; закоммиченный переход делает объект недвижимости Free (таймеры null) → в выборку не попадает. Повтор безопасен (idempotent by state)
Duplicate item (параллельные инстансы)
построчная блокировка с пропуском занятых — объект недвижимости достаётся одному инстансу, второй его пропускает; двойного события нет
Гонка job ↔ consumer crm-property-object-status (запоздавший, но не потерянный release/book)
Объект недвижимости под consumer-ом залочен → job пропускает его в этом цикле. Если consumer закоммитил между чтением и записью job-а — оптимистическая блокировка даёт конфликт версии, job ретраится с перечиткой (объект недвижимости уже Free/Booked → в выборку не попадёт). Если job освободил первым — запоздавший CRM-release видит Free и обрабатывается consumer-ом как идемпотентный no-op (объект недвижимости уже свободен), без CHESSBOARD_PROPERTY_OBJECT_HOLD_MISMATCH
Повторяемая ошибка (инфраструктура)
Откат батча, локи сняты; следующий цикл (60 с) повторяет — те же кандидаты ещё в холде
Poison item
Исключается из батча, ERROR-лог с PropertyObjectId; не блокирует батч; при систематическом повторе — алерт по errors_total, разбор по runbook
cycle started (runId, AsOf, HoldGrace); batch selected (phase, count); WARN lost CRM release (PropertyObjectId, TenantId, ProjectId, HoldDealId, FromStatus, истёкший таймер, overdueBy) — на каждый освобождённый объект недвижимости; cycle completed (counts, DurationMs, SkippedLocked); item failed (PropertyObjectId, exception)
Metrics
chessboard_property_object_hold_expiration_run_total (counter — циклы, liveness); chessboard_property_object_hold_expiration_cycle_duration_seconds (histogram); chessboard_property_object_hold_expiration_items_total{phase="reserved"|"booked"} (counter — каждый инкремент = потерянный release CRM, ключевая метрика); chessboard_property_object_hold_expiration_backlog (gauge — объекты недвижимости в холде с истёкшим на grace таймером, ещё не освобождённые); chessboard_property_object_hold_expiration_skip_locked_total; chessboard_property_object_hold_expiration_errors_total; лаг доставки событий pin.chessboard.property-object-status-changed.v1
Traces
correlationId = id прогона; tenantId (per item, из объекта недвижимости); id прогона — на span цикла и записи исходящего события
Poison-объект недвижимости исключён и залогирован, остальной батч закоммичен; poison — на следующий цикл
Duplicate / параллельные инстансы
построчная блокировка с пропуском занятых — каждый объект недвижимости освобождён один раз, ровно одно событие
Гонка с запоздавшим CRM-release
consumer до тика → объект недвижимости вне выборки либо конфликт версии; job первым → CRM-release видит Free и no-op, без CHESSBOARD_PROPERTY_OBJECT_HOLD_MISMATCH
Cancellation
StopAsync во время цикла → батч докоммичен или откачен целиком, локи сняты, graceful stop