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 team | PIN Engagement |
| Статус документа | draft |
| Область действия | контекст user-activity, таблицы user_activity.sessions / property_object_views / action_events |
| Change context | greenfield; проект Pin.UserActivity.Background (README «Проекты»), рядом с activity-closer |
Классификация
| Поле | Значение |
|---|---|
| Сервис | user-activity |
| Тип job | Scheduled (recurring, MaintenanceJob) |
| Trigger | cron RetentionSchedule — раз в сутки, ночное окно (дефолт 0 0 3 * * *, TZ Europe/Moscow, конфиг) |
| Вход | конфиг ActivityRetention* (RetentionPeriod > 0, BatchSize ∈ 1..50000, MaxBatchesPerRun >= 1); валидируется при старте |
| Критичность | low (maintenance вне денежного и трекингового путей; отказ = рост занятого места, не потеря функции) |
Job бизнес-решения не содержит: получает cron-trigger, берёт параметры очистки из конфигурации под эксклюзивной блокировкой и логирует итог прогона.
Расписание И Locking
| Аспект | Правило |
|---|---|
| Schedule | cron RetentionSchedule (дефолт 0 0 3 * * *, Europe/Moscow); частота — раз в сутки |
| Distributed lock | эксклюзивная блокировка job:scheduled:activity-retention (backend — PostgreSQL advisory, без Redis — ADR-0056); блокировку держит другой pod → лог Info и no-op выход |
| Batch size | BatchSize = 5000 сессий/батч (конфиг); дочерние удаляются set-based по SessionId in batch |
| Max duration / batches | MaxRunDuration = 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 отсутствует.
| Поле | Тип | Обязательность | Источник | Описание |
|---|---|---|---|---|
RetentionPeriod | TimeSpan | Да | конфиг ActivityRetention:RetentionPeriod (дефолт 365 сут ≈ 12 мес) | Порог хранения: сессия удаляема, если EndedAt < now() - RetentionPeriod. |
BatchSize | int | Да | конфиг (дефолт 5000) | Размер батча сессий за одну транзакцию. |
MaxBatchesPerRun | int | Да | конфиг (дефолт 200) | Верхняя граница батчей за прогон (bound по времени/нагрузке). |
RunId | Guid | Да | прогон 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.
Алгоритм
- Контекст. Actor = system (не гость/пользователь), record-level фильтры по
UserId/AnonymousIdне применяются (это maintenance-scan, а не self-scope-чтение);correlationId = RunId. Идемпотентность — структурная (шаг 5), отдельный ключ не нужен. Эксклюзивная блокировка уже захвачена (шаг «Расписание и Locking»). - Проверка входа.
RetentionPeriod > 0;BatchSize ∈ 1..50000;MaxBatchesPerRun >= 1— иначе контролируемый отказ (не исключение). Существование сущностей по id не проверяется — job работает по предикату; допустимость состояния фиксирована предикатом выборки (Status = Inactive). Cross-record scan разрешён явно (нет tenant/owner-границы, ADR-0052). - Что читаем.
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). - Бизнес-правила. Переходов статуса нет (удаление, не мутация состояния). Правило порядка FK (инвариант
очистки): для батча
{session.Id}удаление строгоaction_events→property_object_views→sessions(иначеRestrictнаsessions(id)отклонит удаление родителя). Поддерево одной сессии самодостаточно (PropertyObjectView.ApplyFilterEventIdиViewPropertyObjectActionEvent.PropertyObjectViewIdссылаются на записи той же сессии, обаSetNull— висячих кросс-сессионных ссылок не возникает). - Что меняется (порядок с учётом FK и concurrency): физически удаляются
action_events, затемproperty_object_views, затемsessionsбатча (set-based поSessionId/Id).property_object_view_statsв мутациях отсутствует. - События. Не публикуются: удаление истёкших сырых записей не наблюдаемо потребителями (жизненный цикл
уже отражён
session-ended/property-object-view-endedв момент закрытия;PropertyObjectViewStats/dev-statsнезависимы от наличия сырых строк). - Согласованность. Граница — на батч: три удаления одного батча + освобождение заблокированных строк атомарно; прогон = последовательность независимо коммитящихся батчей: частичный сбой сохраняет уже удалённые батчи (resume — по состоянию данных на следующем запуске, шаг 3), не откатывает весь прогон.
- Результат.
{ DeletedSessions, DeletedPropertyObjectViews, DeletedActionEvents, BatchesProcessed, HasMore }(HasMore = true, если выход поMaxBatchesPerRun/MaxRunDuration, а не по пустому батчу). PII/чувствительных полей нет (только счётчики). Ошибка инфраструктуры → контролируемый отказ + retry hosted-service-ом.
Граница Транзакции
| Элемент | Правило |
|---|---|
| Unit of work | Per batch (BatchSize сессий + их дочерние) — атомарно |
| Сохраняемое состояние | Только удаления action_events/property_object_views/sessions; ни outbox, ни audit, ни checkpoint-строки |
| External calls | Нет (job работает только со своей БД; Kafka/HTTP не вызываются) |
| Частичная ошибка | Continue-safe: закоммиченные батчи финальны, сбойный батч откатывается транзакцией; прогон завершается контролируемым отказом, следующий cron-запуск доберёт остаток (данные-driven resume) |
Идемпотентность И Retry
| Сценарий | Правило |
|---|---|
| Job restart | Resume по состоянию данных: выборка (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) |
Наблюдаемость
| Тип | Правило |
|---|---|
| Logs | Info started (RunId, cutoff, BatchSize); Debug per-batch (batchIndex, счётчики удалений); Info completed (итоговые счётчики, HasMore, duration); Warning skip (lock занят); Error сбой батча (RunId, batchIds). PII нет (только id/счётчики) |
| Metrics | activity_retention_run_duration_seconds; счётчики deleted_sessions / deleted_property_object_views / deleted_action_events; batches_processed; eligible_backlog (кол-во Inactive за cutoff на старте — тренд «не растёт»); lock_contention_total (skip по занятому lock) |
| Traces | correlationId = RunId, job run id; tenantId — не применяется (вне tenant) |
| Alerts | job не отработал > 26 ч (пропуск суточного окна); eligible_backlog растёт N дней подряд (очистка не поспевает — поднять BatchSize/MaxBatchesPerRun); error-rate прогонов; run_duration регулярно близок к MaxRunDuration |
| Dashboard | Grafana 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, закоммиченные батчи сохранены |
Обратные ссылки
Автоссылки: где используется этот документ.
- Use Cases (1): UC-ACT-001. Трекинг сессии гостя и пользователя
Связанные документы
- Домены очищаемых сущностей: ../domain/session.md (retention
Inactive12 мес, порядок 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 года).