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

Background Job: ActivityRetentionJob

Maintenance-job контекста user-activity: физическая очистка (Delete) устаревших закрытых записей активности батчами с соблюдением порядка FK. Дом полей очищаемых сущностей — ../domain/session.md, ../domain/property-object-view.md, ../domain/action-event.md (здесь — только процедура очистки, поля не дублируются).

Назначение

ActivityRetentionJob удаляет из схемы user_activity Inactive-сессии старше срока хранения (RetentionPeriod, дефолт 12 мес — конфиг) вместе с их дочерними action_events/property_object_views, освобождая хранилище от сырой активности, чей срок операционной ценности истёк (ADR-0056: retention 12 мес; долгосрочная аналитика 3 года живёт в dev-stats/ClickHouse, ADR-0057). Тип задачи — MaintenanceJob / retention-cleanup (background-jobs-and-schedulers.md).

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

  • очистка идёт батчами в порядке FK-Restrict: сначала action_events, затем property_object_views, затем sessions (иначе Restrict на sessions(id) отклонит удаление родителя — см. «Связи» в ../domain/session.md);
  • PropertyObjectViewStats НЕ трогается — платформенный агрегат по объекту недвижимости переживает retention сырых просмотров (INV-LVS-002 «счётчики только растут»; пересобираем replay-ем property-object-view-ended/dev-stats);
  • удаление физическое: Session/PropertyObjectView/ActionEvent не поддерживают soft-delete — физическую очистку делает именно этот job;
  • job работает вне tenant (ADR-0052 п.2) — system-actor, cross-record scan разрешён явно (данных с владельцем-tenant в схеме нет).
MetadataЗначение
Owning teamPIN Engagement
Статус документаdraft
Область действияконтекст user-activity, таблицы user_activity.sessions / property_object_views / action_events
Change contextgreenfield; проект Pin.UserActivity.Background (README «Проекты»), рядом с activity-closer

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

ПолеЗначение
Сервисuser-activity
Тип jobScheduled (recurring, MaintenanceJob)
Triggercron RetentionSchedule — раз в сутки, ночное окно (дефолт 0 0 3 * * *, TZ Europe/Moscow, конфиг)
Входконфиг ActivityRetention* (RetentionPeriod > 0, BatchSize ∈ 1..50000, MaxBatchesPerRun >= 1); валидируется при старте
Критичностьlow (maintenance вне денежного и трекингового путей; отказ = рост занятого места, не потеря функции)

Job бизнес-решения не содержит: получает cron-trigger, берёт параметры очистки из конфигурации под эксклюзивной блокировкой и логирует итог прогона.

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

АспектПравило
Schedulecron RetentionSchedule (дефолт 0 0 3 * * *, Europe/Moscow); частота — раз в сутки
Distributed lockэксклюзивная блокировка job:scheduled:activity-retention (backend — PostgreSQL advisory, без Redis — ADR-0056); блокировку держит другой pod → лог Info и no-op выход
Batch sizeBatchSize = 5000 сессий/батч (конфиг); дочерние удаляются set-based по SessionId in batch
Max duration / batchesMaxRunDuration = 30 мин; MaxBatchesPerRun = 200 (конфиг) — защита от бесконечного прогона; недоочищенное берёт следующий суточный запуск
Cancellationотмена прогона (host shutdown) проверяется перед каждым батчем

Distributed lock обязателен: job меняет shared state и может конфликтовать между репликами. Дополнительная страховка от параллелизма — построчная блокировка FOR UPDATE SKIP LOCKED на выборке батча (см. «Алгоритм» шаг 3): даже без lock два прогона не удалят одну строку дважды.

Вход (config)

Job системный, вне tenant (ADR-0052 п.2) — tenant-scope отсутствует.

ПолеТипОбязательностьИсточникОписание
RetentionPeriodTimeSpanДаконфиг ActivityRetention:RetentionPeriod (дефолт 365 сут ≈ 12 мес)Порог хранения: сессия удаляема, если EndedAt < now() - RetentionPeriod.
BatchSizeintДаконфиг (дефолт 5000)Размер батча сессий за одну транзакцию.
MaxBatchesPerRunintДаконфиг (дефолт 200)Верхняя граница батчей за прогон (bound по времени/нагрузке).
RunIdGuidДапрогон jobКорреляция логов/метрик прогона (не персистится).

Порог считает сервер, клиентских timestamp нет. Якорь retention — Session.EndedAt (момент закрытия; у Inactive всегда заполнен, INV-SES-005), не CreatedAt: хранение отсчитывается от закрытия записи.

Предусловия и постусловия

Предусловия запуска:

  • Конфигурация валидна (RetentionPeriod > 0, BatchSize ∈ 1..50000, MaxBatchesPerRun >= 1) — иначе прогон не стартует (контролируемый отказ, не исключение).
  • Эксклюзивная блокировка job:scheduled:activity-retention захвачена (иначе Info-skip и no-op выход).
  • Жизненный цикл удаляемых записей уже отражён потребителям: Inactive-сессия имеет заполненный EndedAt (INV-SES-005), а её просмотры опубликовали property-object-view-ended в момент закрытия. Retention не является сигналом «запись удалена» — к моменту очистки (порог 12 мес) downstream (dev-stats/ClickHouse, PropertyObjectViewStats) давно поглотил закрывающие события; поэтому удаление сырых строк наблюдателям не видно и не требует нового события.

Постусловия успешного прогона:

  • Все сессии с Status = Inactive и EndedAt < now() - RetentionPeriod (в пределах MaxBatchesPerRun) физически удалены вместе со своими action_events/property_object_views; висячих FK не осталось.
  • PropertyObjectViewStats не изменён (INV-LVS-002): накопленные ViewsCount/TotalDuration целы — «сколько раз/как долго» переживает очистку сырых просмотров.
  • Событий не опубликовано, outbox/audit не затронуты; изменилось только занятое место в схеме user_activity.
  • При выходе по лимиту (HasMore = true) остаток заберёт следующий суточный запуск (data-driven resume); никакая уже удовлетворившая порог запись не «застревает» дольше лага ≤ 24 ч + MaxRunDuration.

Алгоритм

  1. Контекст. Actor = system (не гость/пользователь), record-level фильтры по UserId/AnonymousId не применяются (это maintenance-scan, а не self-scope-чтение); correlationId = RunId. Идемпотентность — структурная (шаг 5), отдельный ключ не нужен. Эксклюзивная блокировка уже захвачена (шаг «Расписание и Locking»).
  2. Проверка входа. RetentionPeriod > 0; BatchSize ∈ 1..50000; MaxBatchesPerRun >= 1 — иначе контролируемый отказ (не исключение). Существование сущностей по id не проверяется — job работает по предикату; допустимость состояния фиксирована предикатом выборки (Status = Inactive). Cross-record scan разрешён явно (нет tenant/owner-границы, ADR-0052).
  3. Что читаем. cutoff = now() − RetentionPeriod. В цикле (до MaxBatchesPerRun, MaxRunDuration, отмены) — батч id устаревших сессий: Status = Inactive и EndedAt < cutoff, по EndedAt (старейшие первыми, INV-SES-006 «Inactive неизменяема» ⇒ порядок устойчив), под построчной блокировкой FOR UPDATE SKIP LOCKED (изоляция параллельных прогонов; страховка от гонки с activity-closer, который берёт только Status = Active — пересечения нет). Пустой батч → выход из цикла. PropertyObjectViewStats не читается (не участвует в retention).
  4. Бизнес-правила. Переходов статуса нет (удаление, не мутация состояния). Правило порядка FK (инвариант очистки): для батча {session.Id} удаление строго action_eventsproperty_object_viewssessions (иначе Restrict на sessions(id) отклонит удаление родителя). Поддерево одной сессии самодостаточно (PropertyObjectView.ApplyFilterEventId и ViewPropertyObjectActionEvent.PropertyObjectViewId ссылаются на записи той же сессии, оба SetNull — висячих кросс-сессионных ссылок не возникает).
  5. Что меняется (порядок с учётом FK и concurrency): физически удаляются action_events, затем property_object_views, затем sessions батча (set-based по SessionId/Id). property_object_view_stats в мутациях отсутствует.
  6. События. Не публикуются: удаление истёкших сырых записей не наблюдаемо потребителями (жизненный цикл уже отражён session-ended/property-object-view-ended в момент закрытия; PropertyObjectViewStats/dev-stats независимы от наличия сырых строк).
  7. Согласованность. Граница — на батч: три удаления одного батча + освобождение заблокированных строк атомарно; прогон = последовательность независимо коммитящихся батчей: частичный сбой сохраняет уже удалённые батчи (resume — по состоянию данных на следующем запуске, шаг 3), не откатывает весь прогон.
  8. Результат. { DeletedSessions, DeletedPropertyObjectViews, DeletedActionEvents, BatchesProcessed, HasMore } (HasMore = true, если выход по MaxBatchesPerRun/MaxRunDuration, а не по пустому батчу). PII/чувствительных полей нет (только счётчики). Ошибка инфраструктуры → контролируемый отказ + retry hosted-service-ом.

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

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

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

СценарийПравило
Job restartResume по состоянию данных: выборка (Status = Inactive & EndedAt < cutoff) на следующем запуске естественно исключает уже удалённые строки — отдельный checkpoint не нужен
Duplicate item / повторный прогонСтруктурно идемпотентно: повторное удаление находит 0 строк; построчная блокировка FOR UPDATE SKIP LOCKED + distributed lock не дают двум прогонам конкурировать за один батч
Повторяемая (transient) ошибкаRetry всем прогоном на следующий cron-тик (суточный); внутрипрогонный transient DB-сбой — по политике повторов инфраструктуры
Poison itemНеприменимо — нет «сообщения»; систематически неудаляемый батч (например, неожиданная FK-ссылка) фиксируется в логе с batchIds и алертом на error-rate для ручного разбора (runbook)

SLO Точности И Консистентность

ГарантияЗначение
ПолнотаЛюбая сессия, достигшая порога (Status = Inactive & EndedAt < now() - RetentionPeriod), удаляется в пределах одного суточного цикла после наступления порога (лаг ≤ 24 ч + MaxRunDuration)
Безопасность (no false delete)Записи моложе RetentionPeriod и любые Activeникогда не удаляются (жёсткий предикат Status = Inactive & EndedAt < cutoff); целевой уровень ложных удалений — 0
Сохранность агрегатовPropertyObjectViewStats инвариантно не затрагивается: ViewsCount/TotalDuration не уменьшаются никогда (INV-LVS-002), «сколько раз/как долго» переживает очистку сырых property_object_views
Ссылочная целостностьПосле каждого батча нет висячих FK: порядок action_events → property_object_views → sessions + самодостаточность поддерева сессии (шаг 4)

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

ТипПравило
LogsInfo started (RunId, cutoff, BatchSize); Debug per-batch (batchIndex, счётчики удалений); Info completed (итоговые счётчики, HasMore, duration); Warning skip (lock занят); Error сбой батча (RunId, batchIds). PII нет (только id/счётчики)
Metricsactivity_retention_run_duration_seconds; счётчики deleted_sessions / deleted_property_object_views / deleted_action_events; batches_processed; eligible_backlog (кол-во Inactive за cutoff на старте — тренд «не растёт»); lock_contention_total (skip по занятому lock)
TracescorrelationId = RunId, job run id; tenantId — не применяется (вне tenant)
Alertsjob не отработал > 26 ч (пропуск суточного окна); eligible_backlog растёт N дней подряд (очистка не поспевает — поднять BatchSize/MaxBatchesPerRun); error-rate прогонов; run_duration регулярно близок к MaxRunDuration
DashboardGrafana pin-user-activity (панель retention; README «Runtime И Эксплуатация»)

Связанный Runbook

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

Тесты

ТестОжидание
Empty batchНет Inactive за cutoff → no-op success, счётчики = 0, HasMore = false
Порядок FKСессия с action_events + property_object_views удаляется без Restrict-ошибки; проверка «дети раньше родителя»
Граница порогаInactive с EndedAt = cutoff - 1s удаляется; EndedAt = cutoff + 1s и любая Active — остаются
PropertyObjectViewStats сохранёнПосле удаления property_object_views строка property_object_view_stats по тем же PropertyObjectId не изменилась (счётчики целы)
Partial failureСбой на N-м батче: батчи 1..N-1 закоммичены, следующий запуск доберёт остаток (checkpoint-free resume)
Duplicate runДва параллельных прогона: второй получает отказ от lock (skip) либо FOR UPDATE SKIP LOCKED исключает двойное удаление — строка удалена ровно один раз
Cancellationотмена между батчами → graceful stop, закоммиченные батчи сохранены

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

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

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

  • Домены очищаемых сущностей: ../domain/session.md (retention Inactive 12 мес, порядок FK, INV-SES-005/006), ../domain/property-object-view.md, ../domain/action-event.md (физическое удаление retention-ом); ../domain/property-object-view-stats.md (агрегат вне retention, INV-LVS-002).
  • Соседний worker: activity-closer.md (polling-delta закрытие, будущий; поставляет Inactive-записи, которые потом чистит этот job); обзор — ../README.md.
  • Решения: ADR-0052 (вне tenant), ADR-0056 (simplicity-first: PostgreSQL-only, retention 12 мес), ADR-0057 (dev-stats — долгосрочная аналитика 3 года).