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

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): выборка кандидатов под построчной блокировкой с пропуском занятых, переход и исходящее событие фиксируются в одном согласованном изменении.

ПолеЗначение
OwnerPIN Platform Core
Статус документаdraft
Область действияworker property-object-hold-expiration, таблица chessboard.property-objects, топик pin.chessboard.property-object-status-changed.v1
Assumptions / ограничениятаймеры и штатный release — CRM (job лишь страховка); grace 10 мин — буфер против гонки с CRM; частота срабатываний в норме ≈ 0 (каждое = потерянный release CRM); объём активных холдов 1–10k (ADR-0056), партиционирования нет
ЗависимостиPostgreSQL (chessboard.property-objects, очередь исходящих событий), Kafka (публикация событий); upstream штатного пути — crm/booking-expiration + consumer crm-property-object-status
Change contextgreenfield (новый worker; заявлен в README «Background» и «Проекты»)

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

ПолеЗначение
Сервисchessboard
Тип jobRecurring (polling-worker)
Triggerпериодический тик 1 мин (без внешнего триггера/топика — ADR-0056 п.3)
Критичностьtier-2 (страховка; ошибка → объект недвижимости дольше «залипает» в холде, потери данных нет — переход идемпотентен)

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

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

Входная Модель ExpirePropertyObjectHoldsInput

ПолеТипОбязательностьИсточникОписание
AsOfDateTimeOffsetДасистемные часыМомент цикла для сравнения ReservedUntil/BookedUntil + HoldGrace.
HoldGraceTimeSpanДаконфигурация (default 10 мин)Буфер после истечения таймера, в течение которого штатный release CRM ещё ожидается — job не трогает объект недвижимости.
BatchSizeintДаконфигурация (default 200)Верхняя граница объектов недвижимости за фазу за цикл.
TenantIdTenantIdНетне применяется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-индексу таймера.

  1. Контекст и доступ. Выполняет System-worker (scope-фильтры отключены, cross-tenant разрешён явно); correlationId = id прогона для сквозной трассировки; AsOf = момент цикла. Источник — расписание, не Kafka; бизнес-идемпотентность обеспечивается предикатом статуса (шаг 2), отдельный ключ не нужен.
  2. Проверка. Внешнего ввода нет; существование по id не проверяется (батч-скан). Допустимость состояния встроена в предикаты выборки: только Status = Reserved/Booked с истёкшим на HoldGrace таймером (INV-CHS-002 гарантирует ReservedUntil != null ⇔ Reserved, BookedUntil != null ⇔ Booked); Free/Sold/Hidden не выбираются. Scope — cross-tenant (System-worker).
  3. Что читаем. Кандидаты под построчной блокировкой с пропуском занятых, две выборки на цикл:
    • фаза 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), — без ожидания.
  4. Бизнес-правила и переходы. Для каждого объекта выполняется 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).
  5. Что меняется. Обновляются поля перехода объекта (Status/ReservedUntil/BookedUntil/HoldDealId/StatusSource/StatusChangedAt/UpdatedAt/Version); оптимистическая блокировка защищает от гонки с запоздавшим консюмером CRM (см. «Идемпотентность»).
  6. Какие события фиксируются. Доменное 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, см. «Наблюдаемость»).
  7. Согласованность. Граница — на батч: все переходы батча + audit/versioning + исходящие события на каждый объект фиксируются атомарно; построчные локи держатся до коммита и освобождаются им. Poison-объект (guard кинул при неожиданном состоянии — не должно случаться при корректном предикате) исключается из батча, логируется и остаётся на следующий цикл; остальной батч коммитится (continue, не stop).
  8. Результат. Счётчики цикла (ReservedReleasedCount, BookedReleasedCount, SkippedLockedCount, DurationMs) — для метрик/логов, не HTTP-ответ. Инфраструктурная ошибка → откат транзакции, освобождение локов; ретрай — следующим циклом (те же объекты ещё в холде → выберутся снова, идемпотентно).

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

ЭлементПравило
Unit of workper batch — одна транзакция на весь batch фазы; построчные локи живут до коммита
Сохраняемое состояниепереходы объекта (Status/ReservedUntil/BookedUntil/HoldDealId/StatusSource/StatusChangedAt/Version) + audit + исходящее PropertyObjectStatusChangedKafkaEvent
External callsпубликация события — после коммита (at-least-once); внутри цикла внешних синхронных вызовов нет
Частичная ошибкаcontinue — poison-объект недвижимости исключается из батча и логируется, остальной батч коммитится; инфраструктурный сбой — откат всего батча, ретрай следующим циклом

Идемпотентность И Retry

СценарийПравило
Job restart / повторный цикл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

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

ТипПравило
Logscycle 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)
Metricschessboard_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
TracescorrelationId = id прогона; tenantId (per item, из объекта недвижимости); id прогона — на span цикла и записи исходящего события
DashboardGrafana pin-chessboard (chessboard-overview, панель «срабатывания job истечения»)
Alertschessboard_property_object_hold_expiration_items_total rate > порога за окно (README «Alerts») — систематически теряются release-события CRM; связанный алерт того же класса причин — CHESSBOARD_PROPERTY_OBJECT_HOLD_MISMATCH (INV-CHS-003, consumer crm-property-object-status в DLQ); job не отработал > 2 интервалов (нет прироста run_total) — liveness; backlog > 100 в течение 5 мин (worker отстаёт/завис); errors_total rate > 0 устойчиво; backlog доставки событий > 1000

SLO Цикла

ПоказательЦельОбоснование
Лаг освобождениязалипший холд освобождается не позже timer + HoldGrace + Interval + DurationCycle (≈ таймер + 11 мин)predelta polling ADR-0056; допустимо — это аварийный путь, штатный release CRM укладывается в секунды ≪ grace
Точность (нет ложных срабатываний)job не трогает объект недвижимости в пределах [timer, timer + HoldGrace]grace 10 мин исключает гонку с CRM; срабатывание = штатный путь сломан > 10 мин
Идемпотентность0 повторных освобождений/дублей перехода при рестартах и N инстансахпредикат Reserved/Booked + построчная блокировка с пропуском занятых
Доставка событийat-least-once на каждый освобождённый объект недвижимости; дедуп у потребителей по PropertyObjectId+Versionтранзакционная доставка + идемпотентность consumer-ов

Связанный Runbook

СценарийRunbook
Рост срабатываний (теряются release CRM)../runbooks/property-object-hold-expiration.md (будущий путь): проверить consumer crm-property-object-status (DLQ/лаг), worker crm/booking-expiration, release-топики pin.crm.booking-expired.v1 / pin.crm.booking-cancelled.v1 / pin.crm.deal-cancelled.v1
Ручной release залипшего холда../runbooks/property-object-hold-expiration.md — admin-операция принудительного Release по PropertyObjectId (README «Runbooks»)
Worker завис / backlog растёт../runbooks/property-object-hold-expiration.md: блокировки PostgreSQL, hosted-service/pod, конфиг BatchSize/интервала
Лаг доставки событий pin.chessboard.property-object-status-changed.v1../runbooks/outbox-backlog.md (будущий путь)

Тесты

ТестОжидание
Empty batchНет холдов с истёкшим на grace таймером → no-op success, событий/мутаций нет
В пределах graceReserved/Booked c timer < now, но timer + grace >= nowне выбирается (штатный release CRM ещё ожидается)
Резерв залипReserved, ReservedUntil + grace < nowFree, ReservedUntil/HoldDealId = null, StatusSource = ExpirationJob, WARN, исходящее PropertyObjectStatusChangedKafkaEvent(To=Free)
Бронь залиплаBooked, BookedUntil + grace < nowFree, BookedUntil/HoldDealId = null, WARN, исходящее событие статуса
ProjectAggregatesRecalcосвобождение поднимает ProjectAggregatesRecalcRequestedEvent (пересчёт PropertyObjectsFreeCount debounce-job-ом)
Partial failurePoison-объект недвижимости исключён и залогирован, остальной батч закоммичен; poison — на следующий цикл
Duplicate / параллельные инстансыпострочная блокировка с пропуском занятых — каждый объект недвижимости освобождён один раз, ровно одно событие
Гонка с запоздавшим CRM-releaseconsumer до тика → объект недвижимости вне выборки либо конфликт версии; job первым → CRM-release видит Free и no-op, без CHESSBOARD_PROPERTY_OBJECT_HOLD_MISMATCH
CancellationStopAsync во время цикла → батч докоммичен или откачен целиком, локи сняты, graceful stop

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

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

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