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 team | PIN 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 |
| Тип job | Scheduled (recurring, maintenance) |
| Trigger | cron — раз в сутки, ночное окно (дефолт 0 30 3 * * *, TZ Europe/Moscow, конфиг) |
| Критичность | low (maintenance вне публичного read-пути и денежного потока; отказ = рост занятого места + «мусорные» Delisted-строки, не потеря функции) |
Расписание и locking
| Аспект | Правило |
|---|
| Schedule | cron DelistedRetentionSchedule (дефолт 0 30 3 * * *, Europe/Moscow); частота — раз в сутки |
| Distributed lock | PostgreSQL advisory-lock job:scheduled:catalog-delisted-retention (без Redis); если lock держит другой инстанс → лог Info и no-op выход |
| Batch size | BatchSize = 2000 карточек/батч на фазу (конфиг); связанные оверрайды удаляются одним запросом по id/project_id батча |
| Max duration / batches | MaxRunDuration = 20 мин; MaxBatchesPerRun = 100 (конфиг) — bound по времени; недоочищенное берёт следующий суточный запуск |
| Остановка | остановка воркера проверяется перед каждым батчем; текущий батч докоммичивается/откатывается целиком |
Distributed lock отсекает наложение суточных прогонов между репликами. Основная страховка от параллелизма
— построчная блокировка выборки батча (FOR UPDATE SKIP LOCKED): даже без lock два прогона или
консюмер-восстановление не удалят и не тронут одну строку дважды.
| Поле | Тип | Обязательность | Источник | Описание |
|---|
TenantId | — | — | не применяется — вне tenant | Витрина platform-level; tenant-scope отсутствует. |
RetentionPeriod | TimeSpan | Да | конфиг DelistedRetention:RetentionPeriod (дефолт 30 сут) | Порог: карточка удаляема, если Status = Delisted ∧ UpdatedAt < now() - RetentionPeriod. |
BatchSize | int | Да | конфиг (дефолт 2000) | Размер батча карточек за одну транзакцию (на фазу). |
MaxBatchesPerRun | int | Да | конфиг (дефолт 100) | Верхняя граница батчей за прогон (bound по времени/нагрузке). |
RunId | Guid | Да | воркер | Корреляция логов/метрик прогона (не персистится). |
Порог считает сервер (системное время); клиентских timestamp-ов нет.
Алгоритм
- Контекст и доступ. Актор — системный, обработка вне tenant (tenant/record-level фильтры не
применяются, сквозной скан разрешён явно);
cutoff = now() − RetentionPeriod. Идемпотентность —
структурная (data-driven, шаг 4). Distributed lock уже захвачен воркером.
- Проверка входа.
RetentionPeriod > 0; BatchSize ∈ [1..10000]; MaxBatchesPerRun >= 1 — иначе
ValidationError. Существование сущностей по id не применяется (скан по предикату). Допустимость
состояния фиксирована предикатом (Status = Delisted), не проверкой загруженного агрегата.
- Что читаем. Две фазы за прогон, порядок «объекты недвижимости → проект». Фаза 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) снимает строку с
предиката — она не попадёт в выборку.
- Бизнес-правила. Переходов статуса нет (физическое удаление). Правило якоря:
UpdatedAt < cutoff ∧ Status = Delisted ⇔ делистнута > RetentionPeriod назад (обоснование заморозки — «Назначение»).
Правило связанной очистки (orphan-cleanup): удаляемым объектам недвижимости соответствуют
PropertyObjectDisplayOverride (по property_object_id), удаляемым проектам — BuildingDisplayOverride
(по project_id); оба — витринные display-правки без внешних потребителей, после удаления карточки
их цель исчезает. Не трогается DeveloperRankingRule; данные продвижения — в контексте promotion,
не в схеме catalog.
- Что меняется. Фаза A на батч: удаление
property_object_display_overrides батча (включая ранее
снятые, soft-deleted оверрайды), затем удаление карточек property_object_cards батча. Фаза B на батч:
удаление building_display_overrides батча, затем project_cards батча. Все удаления — физические по
предикату; ranking_rules не затрагиваются; таблиц продвижения в схеме catalog нет.
- Результат. Сводка
{ 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); порядок «объекты недвижимости → проект» исключает висячие карточки объекта недвижимости при удалённом проекте |
Наблюдаемость
| Тип | Правило |
|---|
| Logs | Info started (RunId, cutoff, BatchSize); Debug per-batch (phase, batchIndex, счётчики); Info completed (итоговые счётчики, HasMore, duration); Warning skip (lock занят); Error сбой батча (RunId, batchIds). PII нет (только id/счётчики) |
| Metrics | catalog_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 |
| Traces | correlationId = RunId; tenantId — не применяется (вне tenant) |
| Alerts | job не отработал > 26 ч (пропуск суточного окна); catalog_delisted_backlog растёт N дней подряд (поднять BatchSize/MaxBatchesPerRun); error-rate прогонов; run_duration регулярно близок к MaxRunDuration |
| Dashboard | Grafana 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, закоммиченные батчи сохранены |
Обратные ссылки
Автоссылки: где используется этот документ.
Связанные документы