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

Background Job: DelistedRetentionJob

Maintenance-job контекста catalog: физическая очистка карточек витрины в статусе Delisted старше 30 сут — батчами, в порядке «объекты недвижимости → проект». Дом полей очищаемых проекций — ../domain/catalog-property-object-card.md, ../domain/catalog-project-card.md (здесь — только процедура очистки, поля не дублируются).

Назначение

DelistedRetentionJob удаляет из схемы catalog Delisted-карточки объектов недвижимости и проектов, у которых с момента делистинга прошло больше RetentionPeriod (дефолт 30 сут), освобождая витрину от карточек объектов, удалённых в источнике (chessboard прислал PropertyObjectDeleted/ProjectDeleted). Делистинг сначала переводит карточку в терминальный Delisted (окно для восстановления Delisted → Published при повторном upsert-е источника), а по истечении окна retention-job физически удаляет строку (переход Delisted --> [*] в domain-доках).

Якорь давности делистинга — UpdatedAt + guard Status = Delisted. Явного DelistedAt в домене нет. UpdatedAt пригоден как прокси, потому что после перехода в Delisted карточка заморожена: консюмеры игнорируют события по Delisted-карточке, кроме восстановительного upsert-а (объект недвижимости — INV-7 DelistedFrozen; проект — INV-5 CascadeHide ставит UpdatedAt всех объектов недвижимости проекта в момент делистинга). Значит UpdatedAt фиксируется на моменте делистинга и не сдвигается до восстановления/удаления.

MetadataЗначение
Owning teamPIN Marketplace
Статус документаdraft
Область действияконтекст catalog, таблицы catalog.property_object_cards / project_cards (+ orphan property_object_display_overrides / building_display_overrides)
Assumptions / ограниченияINV-7/INV-5 держат UpdatedAt Delisted-карточки неизменным; ни один консюмер/job не мутирует Delisted-строку (popularity-recompute работает только по Published) — иначе клок retention сдвинется (проверить при реализации); целевой объём Delisted — сотни-тысячи строк
ЗависимостиPostgreSQL (catalog.property_object_cards/project_cards/оверрайды); Kafka/HTTP не вызываются

Ключевые правила:

  • очистка батчами в порядке «объекты недвижимости → проект»: property_object_cards.project_id → project_cards.idлогическая ссылка без FK (INV-2), порядок держит логика job-а (не каскадное удаление БД), чтобы не оставлять временно висячих карточек объекта недвижимости при удалённом проекте;
  • удаление физическое: карточки — реплика, soft-delete неприменим, физическую очистку делает именно этот job;
  • ссылочная целостность избранного/подборок — по id: favorites/selectionsчужие сервисы, ссылаются на карточки по стабильному Id (= id chessboard) и переживают удаление (id остаётся валидным ключом; чтение по нему после удаления даёт 404 → graceful-деградация). Событие удаления не публикуется;
  • не трогает DeveloperRankingRule (переживает делистинг для реактивации при восстановлении — ranking-rule.md) и данные продвижения: покупки/пакеты живут в контексте promotion; при делистинге цели вклад буста снимает консюмер promotion-boost.md, сама покупка доживает срок в promotion;
  • job работает вне tenant — системный актор, сквозной скан всех записей разрешён явно.

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

ПолеЗначение
Сервисcatalog
Тип jobScheduled (recurring, maintenance)
Triggercron — раз в сутки, ночное окно (дефолт 0 30 3 * * *, TZ Europe/Moscow, конфиг)
Критичностьlow (maintenance вне публичного read-пути и денежного потока; отказ = рост занятого места + «мусорные» Delisted-строки, не потеря функции)

Расписание и locking

АспектПравило
Schedulecron DelistedRetentionSchedule (дефолт 0 30 3 * * *, Europe/Moscow); частота — раз в сутки
Distributed lockPostgreSQL advisory-lock job:scheduled:catalog-delisted-retention (без Redis); если lock держит другой инстанс → лог Info и no-op выход
Batch sizeBatchSize = 2000 карточек/батч на фазу (конфиг); связанные оверрайды удаляются одним запросом по id/project_id батча
Max duration / batchesMaxRunDuration = 20 мин; MaxBatchesPerRun = 100 (конфиг) — bound по времени; недоочищенное берёт следующий суточный запуск
Остановкаостановка воркера проверяется перед каждым батчем; текущий батч докоммичивается/откатывается целиком

Distributed lock отсекает наложение суточных прогонов между репликами. Основная страховка от параллелизма — построчная блокировка выборки батча (FOR UPDATE SKIP LOCKED): даже без lock два прогона или консюмер-восстановление не удалят и не тронут одну строку дважды.

Входная модель DelistedRetentionInput

ПолеТипОбязательностьИсточникОписание
TenantIdне применяется — вне tenantВитрина platform-level; tenant-scope отсутствует.
RetentionPeriodTimeSpanДаконфиг DelistedRetention:RetentionPeriod (дефолт 30 сут)Порог: карточка удаляема, если Status = Delisted ∧ UpdatedAt < now() - RetentionPeriod.
BatchSizeintДаконфиг (дефолт 2000)Размер батча карточек за одну транзакцию (на фазу).
MaxBatchesPerRunintДаконфиг (дефолт 100)Верхняя граница батчей за прогон (bound по времени/нагрузке).
RunIdGuidДаворкерКорреляция логов/метрик прогона (не персистится).

Порог считает сервер (системное время); клиентских timestamp-ов нет.

Алгоритм

  1. Контекст и доступ. Актор — системный, обработка вне tenant (tenant/record-level фильтры не применяются, сквозной скан разрешён явно); cutoff = now() − RetentionPeriod. Идемпотентность — структурная (data-driven, шаг 4). Distributed lock уже захвачен воркером.
  2. Проверка входа. RetentionPeriod > 0; BatchSize ∈ [1..10000]; MaxBatchesPerRun >= 1 — иначе ValidationError. Существование сущностей по id не применяется (скан по предикату). Допустимость состояния фиксирована предикатом (Status = Delisted), не проверкой загруженного агрегата.
  3. Что читаем. Две фазы за прогон, порядок «объекты недвижимости → проект». Фаза A (объекты недвижимости) — цикл (до MaxBatchesPerRun/MaxRunDuration/остановки): батч property_object_cards с Status = Delisted ∧ UpdatedAt < cutoff, по возрастанию UpdatedAt, под построчной блокировкой (FOR UPDATE SKIP LOCKED); пустой батч → фаза B. Фаза B (проекты) — аналогично по project_cards (объекты недвижимости делистнутого проекта уже Delisted каскадом INV-5 и удалены в фазе A). Восстановление источником (Delisted → Published) снимает строку с предиката — она не попадёт в выборку.
  4. Бизнес-правила. Переходов статуса нет (физическое удаление). Правило якоря: UpdatedAt < cutoff ∧ Status = Delisted ⇔ делистнута > RetentionPeriod назад (обоснование заморозки — «Назначение»). Правило связанной очистки (orphan-cleanup): удаляемым объектам недвижимости соответствуют PropertyObjectDisplayOverride (по property_object_id), удаляемым проектам — BuildingDisplayOverride (по project_id); оба — витринные display-правки без внешних потребителей, после удаления карточки их цель исчезает. Не трогается DeveloperRankingRule; данные продвижения — в контексте promotion, не в схеме catalog.
  5. Что меняется. Фаза A на батч: удаление property_object_display_overrides батча (включая ранее снятые, soft-deleted оверрайды), затем удаление карточек property_object_cards батча. Фаза B на батч: удаление building_display_overrides батча, затем project_cards батча. Все удаления — физические по предикату; ranking_rules не затрагиваются; таблиц продвижения в схеме catalog нет.
  6. Результат. Сводка { DeletedPropertyObjectCards, DeletedProjectCards, DeletedOverrides, BatchesProcessed, HasMore } (HasMore = true при выходе по MaxBatchesPerRun/MaxRunDuration). PII нет. Граница согласованности — на батч: удаление оверрайдов + карточек одного батча + освобождение блокировок в одном согласованном изменении. Прогон = последовательность независимо коммитящихся батчей: частичный сбой сохраняет удалённые батчи (data-driven resume), не откатывает весь прогон; инфраструктурная ошибка → проброс для повтора следующим прогоном.

Граница согласованности

ЭлементПравило
Единица измененияна батч (BatchSize карточек одной фазы + их оверрайды) в одном согласованном изменении
Сохраняемое состояниеТолько удаления property_object_cards/project_cards и связанных *_display_overrides; ни outbox, ни audit, ни checkpoint-строки
External callsНет (job работает только со своей БД; Kafka/HTTP не вызываются)
Частичная ошибкаContinue-safe: закоммиченные батчи финальны, сбойный батч откатывается; прогон падает, следующий cron-запуск доберёт остаток (data-driven resume)

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

СценарийПравило
Job restart / повторный прогонResume по состоянию данных: предикат (Status = Delisted & UpdatedAt < cutoff) на следующем запуске естественно исключает уже удалённые строки — checkpoint не нужен
Duplicate item / параллельные инстансыСтруктурно идемпотентно: повторное удаление по предикату находит 0 строк; distributed lock + построчная блокировка не дают двум прогонам конкурировать за один батч
Гонка retention ↔ восстановление источникаКонсюмер PropertyObjectUpserted/ProjectUpserted восстанавливает Delisted → Published: если восстановление раньше — строка вне предиката (skip); если retention залочил первым — карточка удаляется, восстановление создаёт свежую карточку с тем же id (реставрация после 30 дней = новая публикация; избранное/подборки по id продолжают работать) — оба исхода консистентны
Повторяемая (transient) ошибкаRetry всем прогоном на следующий cron-тик; внутрипрогонный transient DB-сбой — по политике повторов инфраструктуры
Poison itemНеприменимо — нет «сообщения»; систематически неудаляемый батч фиксируется в логе с batchIds и алертом error-rate для ручного разбора (runbook)

SLO цикла и консистентность

ГарантияЗначение
ПолнотаЛюбая карточка, достигшая порога (Status = Delisted & UpdatedAt < now() - RetentionPeriod), удаляется в пределах одного суточного цикла после наступления порога (лаг ≤ 24 ч + MaxRunDuration)
Безопасность (no false delete)Published/Hidden-карточки и любые Delisted моложе RetentionPeriodникогда не удаляются (жёсткий предикат); целевой уровень ложных удалений — 0
Ссылочная целостностьИзбранное/подборки (favorites/selections) ссылаются по стабильному Id и переживают удаление; DeveloperRankingRule не затрагивается (продвижение — контекст promotion); порядок «объекты недвижимости → проект» исключает висячие карточки объекта недвижимости при удалённом проекте

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

ТипПравило
LogsInfo started (RunId, cutoff, BatchSize); Debug per-batch (phase, batchIndex, счётчики); Info completed (итоговые счётчики, HasMore, duration); Warning skip (lock занят); Error сбой батча (RunId, batchIds). PII нет (только id/счётчики)
Metricscatalog_delisted_retention_run_duration_seconds (histogram); catalog_delisted_deleted_property_object_cards_total / _deleted_project_cards_total / _deleted_overrides_total; catalog_delisted_retention_batches_processed; catalog_delisted_backlog (gauge — всего Delisted-карточек и из них за cutoff); catalog_delisted_retention_lock_contention_total
TracescorrelationId = RunId; tenantId — не применяется (вне tenant)
Alertsjob не отработал > 26 ч (пропуск суточного окна); catalog_delisted_backlog растёт N дней подряд (поднять BatchSize/MaxBatchesPerRun); error-rate прогонов; run_duration регулярно близок к MaxRunDuration
DashboardGrafana catalog-overview (панель «размер Delisted»)

Кросс-сервисный контракт и деградация

НаправлениеКонтрактПоведение при отсутствии/устаревании
Ожидает (вход)Delisted-карточки заморожены: UpdatedAt зафиксирован на моменте делистинга и не сдвигается до восстановления/удаления (INV-7 DelistedFrozen объекта недвижимости, INV-5 CascadeHide проекта); ни один консюмер/job не мутирует Delisted-строкуЕсли консюмер ошибочно тронет UpdatedAt Delisted-строки, её «часы retention» сбросятся и удаление отложится на новый период (не потеря данных, а задержка очистки) — контролируется метрикой catalog_delisted_backlog и алертом на рост
Предоставляет (выход)Интеграционных/доменных событий нет — событие удаления карточки не публикуется. Внешние потребители (favorites, selections) ссылаются на карточки по стабильному Id (= id chessboard) и обнаруживают удаление лениво: чтение по несуществующему id → 404 → graceful-деградация на их сторонеПотребители, кэширующие карточки по id, увидят исчезновение только при следующем чтении; промежуточного «мягкого» состояния витрина не отдаёт — публичные GET уже возвращают 404 на Delisted/Hidden, поэтому конечный пользователь Delisted-карточку не видит ещё до физического удаления

Связанный runbook

СценарийRunbook
Delisted-backlog растёт (очистка отстаёт)../runbooks/delisted-retention-backlog.md (будущий): поднять BatchSize/MaxBatchesPerRun, проверить размер таблиц/vacuum
Прогон падает на батче (DB)../runbooks/delisted-retention-failure.md (будущий): разбор по RunId/batchIds, ручной повтор после фикса
Lock не освобождается / зависший инстанс../runbooks/delisted-retention-lock.md (будущий): проверить PG advisory-lock сессию

Тесты

ТестОжидание
Empty batchНет Delisted за cutoff → no-op success, счётчики = 0, HasMore = false
Граница порогаDelisted с UpdatedAt = cutoff - 1s удаляется; UpdatedAt = cutoff + 1s, любые Published/Hidden — остаются
Порядок объекты недвижимости→проектДелистнутый проект + его объекты недвижимости: объекты удалены в фазе A, проект в фазе B; нет висячей карточки объекта на удалённый проект
Orphan-оверрайдыPropertyObjectDisplayOverride/BuildingDisplayOverride (live и soft-deleted) удаляемых карточек физически удалены; DeveloperRankingRule цел (продвижение — контекст promotion)
Избранное переживаетПосле удаления карточки favorites/selections по её id остаются (id — валидный ключ, потребитель получает 404 gracefully)
Partial failureСбой на N-м батче: батчи 1..N-1 закоммичены, следующий запуск доберёт остаток (checkpoint-free resume)
Duplicate / параллельные инстансыВторой прогон получает занятый lock (skip) либо FOR UPDATE SKIP LOCKED исключает двойное удаление
Гонка с восстановлениемPropertyObjectUpserted/ProjectUpserted до/во время прогона → карточка либо вне предиката (skip), либо удалена и пересоздана свежей с тем же id
Остановка воркераостановка между батчами → graceful stop, закоммиченные батчи сохранены

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

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

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