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

Background Job: GuestFavoritesCleanupJob (retention гостевого избранного)

Назначение

GuestFavoritesCleanupJob — единственная фоновая джоба контекста favorites. Раз в сутки физически удаляет протухшее гостевое избранное — записи Favorite без владельца-пользователя (user_id IS NULL, владелец — AnonymousId), у которых с последней активности прошло больше RetentionDays. Цель — не дать гостевым лайкам копиться бесконечно (гость приходит, лайкает без входа и не возвращается), при этом пользовательские записи (user_id IS NOT NULL) не трогаются никогда.

Почему retention по времени, а не событийная чистка: consumer на pin.user-activity.session-ended.v1 отклонён (../consumers/README.md, кандидат №2) — завершение сессии (polling-delta 10–15 мин) не означает отказа от избранного: гость вернётся завтра в том же браузере с тем же AnonymousId и ждёт свои лайки. Retention по UpdatedAt проще и без связности с user-activity (ADR-0056 simplicity-first).

MetadataЗначение
Owning teamPIN Engagement
Статус документаdraft
Область действияконтекст favorites, таблица favorites.favorites
Assumptions/ограниченияПорог RetentionDays — конфигурация; объём мал (≤ 1 млн пользователей × десятки записей, без партиционирования — ADR-0056); чистятся только гостевые записи, аккаунтные живут бессрочно
ЗависимостиPostgreSQL (schema favorites). Kafka не задействован (job не публикует событий). Redis отсутствует (ADR-0056)

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

ПолеЗначение
Сервисfavorites
Тип jobRecurring (суточный тик по расписанию)
Triggerвнутренний scheduler/таймер, Favorites:GuestRetention:Cron (default 0 3 * * *, daily UTC)
Критичностьlow (чистка вне денежного и пользовательского пути; отставание лишь копит мёртвые гостевые строки)

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

АспектПравило
Scheduleсуточный тик Favorites:GuestRetention:Cron (default 0 3 * * *, UTC — ночное окно низкой нагрузки)
Параллельные инстансыБезопасны без выделенного распределённого лока: выборка батча берёт строки с пропуском уже заблокированных другим инстансом, поэтому разные инстансы забирают непересекающиеся строки и двойное удаление невозможно; Redis отсутствует (ADR-0056)
Batch sizeFavorites:GuestRetention:BatchSize строк за проход (default 1000); прогон повторяет выборку+удаление, пока батч полон; backlog разгребается за несколько итераций
Max durationFavorites:GuestRetention:MaxRunDuration (default 10 мин) — предохранитель; при превышении прогон завершается штатно, остаток берёт следующий суточный тик
Graceful stopсигнал остановки хоста/таймера учитывается между батчами (после фиксации каждого) — без обрыва открытой транзакции

Входная Модель GuestFavoritesCleanupOptions (config)

Внешнего runtime-входа нет; поведение задаётся конфигом (валидация на старте хоста).

ПолеТипОбязательностьИсточникОписание
DaysintДаconfig (Favorites:GuestRetention:Days, default 365)Порог retention в днях от UpdatedAt (последней активности). > 0.
BatchSizeintДаconfig (Favorites:GuestRetention:BatchSize, default 1000)Размер батча удаления за одну транзакцию. > 0.
CronstringДаconfig (Favorites:GuestRetention:Cron, default 0 3 * * *)Расписание суточного запуска (UTC).
MaxRunDurationTimeSpanДаconfig (default 00:10:00)Верхняя граница одного прогона (предохранитель).

Actor — system (worker), не self-scope; контекст вне tenant (ADR-0052 п.2): скан идёт по всем гостевым строкам безотносительно AnonymousId — задокументированный system-maintenance cross-owner scan, публичного входа у job нет.

Алгоритм

Отсечка cutoff = now() − RetentionDays. Строгий предикат user_id IS NULL AND updated_at < cutoff — единственное условие удаления; он же защищает аккаунтные записи (аналог INV-FAV-005 на уровне джобы).

  1. Контекст. Суточный тик; actor = system, correlationId = jobRunId (генерируется на прогон), scope — системный (вне tenant). Идемпотентность обеспечивает сам предикат (шаг 5), а не ключ.
  2. Проверка входа. Внешних данных нет — проверяется только конфиг при старте (Days > 0, BatchSize > 0, валидный Cron) → иначе fail-fast хоста. Вычислить cutoff = now() − Days.
  3. Что читаем (под блокировкой). Выбрать только Id протухших гостевых записей (user_id IS NULL AND updated_at < cutoff), отсортированных по updated_at, не более BatchSize; строки, уже взятые параллельным инстансом, пропускаются. Индекс ix_favorites_created_at / updated_at обслуживает выборку и сортировку. Сущности в память не грузятся — Note/остальные поля не читаются.
  4. Бизнес-правила. Переходов состояний нет: у Favorite нет поля Status, жизненный цикл заканчивается физическим удалением (soft delete не применяется — favorite.md). Единственное правило — строгий guard user_id IS NULL: записи с user_id IS NOT NULL в выборку не входят и не удаляются.
  5. Что меняется. Батч удаляется одним set-based DELETE по предикату user_id IS NULL AND updated_at < cutoff AND id ∈ выбранные — guard user_id IS NULL продублирован в предикате удаления как defense-in-depth. Итерация: шаги 3→5 повторяются, пока батч полон; неполный батч означает исчерпание — прогон завершается.
  6. События. Не публикуются. Kafka-событие не шлётся: retention — техническая чистка, не действие пользователя; потребители user-activity получают только факты add/remove/merge, в ActionEvent retention не идёт. Доменное FavoriteRemovedEvent тоже не поднимается — это не пользовательское удаление.
  7. Согласованность. Каждый батч — своя транзакция; долгий общий lock не берётся; зафиксированный батч остаётся удалённым даже при сбое следующего. Внешних вызовов внутри транзакции нет (Kafka не задействован).
  8. Результат. Job наружу ничего не возвращает, а пишет метрики прогона (удалено записей, число батчей, длительность, остался ли backlog). Пустой прогон — no-op success. Инфраструктурная ошибка на батче — лог + завершение прогона; остаток берёт следующий суточный тик (идемпотентно).

Предусловия И Постусловия Прогона

МоментГарантия
ПредусловиеКонфиг валиден (Days > 0, BatchSize > 0, корректный Cron); доступна схема favorites в PostgreSQL. Job автономен: Kafka и user-activity не требуются (событий не шлёт, синхронных вызовов не делает)
Постусловие (прогон дошёл до исчерпания)В таблице нет гостевых записей (user_id IS NULL) с updated_at < cutoff; аккаунтные записи (user_id IS NOT NULL) не изменены и не удалены; ни одно интеграционное/доменное событие не порождено — retention невидим для user-activity/CRM (интеграторам ленты действий на удаление гостевых лайков рассчитывать нельзя)
Постусловие (прогон прерван по MaxRunDuration/сигналу)Зафиксированные батчи удалены безвозвратно; остаток протухших записей гарантированно уберёт следующий суточный тик (свежесть — SLO ниже)

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

ЭлементПравило
Unit of workper-batch — одна транзакция на батч
Сохраняемое состояниетолько удаление строк favorites.favorites (user_id IS NULL); исходящие события/audit не пишутся (событий нет)
External callsотсутствуют (Kafka не задействован; удаление целиком в PostgreSQL)
Частичная ошибкаcontinue-by-run: сбой фиксации батча логируется, прогон завершается; уже зафиксированные батчи сохраняются; остаток берёт следующий тик

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

СценарийПравило
Job restart / повторный прогонИдемпотентен: предикат user_id IS NULL AND updated_at < cutoff при повторе просто не находит уже удалённых строк — второй проход удаляет 0. Checkpoint не нужен
Параллельные инстансыБезопасны: пропуск заблокированных строк разводит батчи по строкам; одну строку удаляет ровно один инстанс
Duplicate itemНевозможно: удалённой строки в выборке больше нет
Повторяемая ошибкаИнфраструктурный сбой — батч откатывается, строки остаются, следующий суточный тик повторяет; ручной прогон безопасен (идемпотентно)
Гонка с пользовательской активностьюГость обновил запись (лайк/заметка) → UpdatedAt сдвинулся вперёд, строка выпадает из updated_at < cutoff и не удаляется. Слияние проставило user_id → строка выпадает по user_id IS NULL

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

ТипПравило
Logsjob started (jobRunId, cutoff, RetentionDays); batch deleted (batchNo, rows); job completed (deletedTotal, batches, durationMs, backlogRemaining); job failed (jobRunId, batchNo, exception). AnonymousId/Note в логи не пишутся (приватные данные)
Metricsfavorites_guest_cleanup_deleted_total (counter — удалено за прогон); favorites_guest_cleanup_run_duration_ms (histogram); favorites_guest_cleanup_batches_total (counter); favorites_guest_cleanup_errors_total (counter); favorites_guest_cleanup_backlog (gauge — осталось подходящих под cutoff после прогона)
Tracesspan на прогон с jobRunId, атрибуты retentionDays, batchSize, deletedTotal; вложенные span на батч
Dashboardfavorites-overview (../README.md, «Runtime») — панель удалений retention и длительности прогона
Alerts«ошибки job-а cleanup» (README «Alerts») — рост favorites_guest_cleanup_errors_total; отсутствие успешного прогона > 48 ч; аномальный deleted_total (резкий скачок — проверить корректность cutoff)

SLO

ПоказательЦельОбоснование
Свежесть чисткинет гостевых записей с updated_at старше RetentionDays + 1 суткисуточный тик + предохранитель MaxRunDuration; контроль ..._backlog
Сохранность аккаунтных записей0 удалённых строк с user_id IS NOT NULLстрогий предикат user_id IS NULL (аналог INV-FAV-005); тест изоляции обязателен
Идемпотентность0 ошибок и 0 «двойных» удалений при рестартах и N инстансахпредикат + пропуск заблокированных строк
Успешность прогона≥ 1 успешный прогон в суткиалерт на отсутствие прогона > 48 ч

Связанный Runbook

СценарийRunbook
Сбой прогона (errors_total растёт)проверить доступность PostgreSQL/схемы favorites; повторный ручной прогон безопасен (идемпотентно) — остаток удалится
Backlog не падаетувеличить BatchSize/MaxRunDuration; проверить, что updated_at-индекс используется (план запроса), нет ли долгих блокировок
Подозрение на лишнее удалениесверить предикат (user_id IS NULL) по логам deletedTotal; восстановление аккаунтных записей — из бэкапа (undo нет)

Тесты

ТестОжидание
Empty runнет гостевых записей старше cutoff — no-op success, удалено 0
Протухшая гостевая записьuser_id IS NULL и updated_at < cutoff — удалена физически
Свежая гостевая записьupdated_at >= cutoff — не удалена
Аккаунтная записьuser_id IS NOT NULL (даже с updated_at < cutoff) — не удалена (изоляция)
Батчингзаписей больше BatchSize — удаляются за несколько батчей, каждый в своей транзакции
Идемпотентность / повторный прогонвторой прогон по тем же данным удаляет 0
Параллельные инстансыдве копии worker-а не удаляют одну строку дважды
Graceful stopостановка между батчами — зафиксированные батчи сохранены, открытая транзакция не рвётся
Событий нетни Kafka-, ни доменное FavoriteRemovedEvent не публикуются при retention

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

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

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

  • Дом полей/инвариантов/индексов: ../domain/favorite.md (INV-FAV-005, ix_favorites_created_at, «Партиционирование / retention», физическое удаление без soft delete).
  • Обоснование «retention-job, а не consumer»: ../consumers/README.md (кандидат №2).
  • Обзор/эксплуатация: ../README.md («Runtime И Эксплуатация» — Alerts/Runbooks, «Известные Ограничения» — 365 дней/конфигурация).
  • Решения: ADR-0052 (вне tenant), ADR-0056 (simplicity-first: PostgreSQL-only, без Redis, retention по времени).