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 team | PIN Engagement |
| Статус документа | draft |
| Область действия | контекст favorites, таблица favorites.favorites |
| Assumptions/ограничения | Порог RetentionDays — конфигурация; объём мал (≤ 1 млн пользователей × десятки записей, без партиционирования — ADR-0056); чистятся только гостевые записи, аккаунтные живут бессрочно |
| Зависимости | PostgreSQL (schema favorites). Kafka не задействован (job не публикует событий). Redis отсутствует (ADR-0056) |
Классификация
| Поле | Значение |
|---|
| Сервис | favorites |
| Тип job | Recurring (суточный тик по расписанию) |
| Trigger | внутренний scheduler/таймер, Favorites:GuestRetention:Cron (default 0 3 * * *, daily UTC) |
| Критичность | low (чистка вне денежного и пользовательского пути; отставание лишь копит мёртвые гостевые строки) |
Расписание И Locking
| Аспект | Правило |
|---|
| Schedule | суточный тик Favorites:GuestRetention:Cron (default 0 3 * * *, UTC — ночное окно низкой нагрузки) |
| Параллельные инстансы | Безопасны без выделенного распределённого лока: выборка батча берёт строки с пропуском уже заблокированных другим инстансом, поэтому разные инстансы забирают непересекающиеся строки и двойное удаление невозможно; Redis отсутствует (ADR-0056) |
| Batch size | Favorites:GuestRetention:BatchSize строк за проход (default 1000); прогон повторяет выборку+удаление, пока батч полон; backlog разгребается за несколько итераций |
| Max duration | Favorites:GuestRetention:MaxRunDuration (default 10 мин) — предохранитель; при превышении прогон завершается штатно, остаток берёт следующий суточный тик |
| Graceful stop | сигнал остановки хоста/таймера учитывается между батчами (после фиксации каждого) — без обрыва открытой транзакции |
Входная Модель GuestFavoritesCleanupOptions (config)
Внешнего runtime-входа нет; поведение задаётся конфигом (валидация на старте хоста).
| Поле | Тип | Обязательность | Источник | Описание |
|---|
Days | int | Да | config (Favorites:GuestRetention:Days, default 365) | Порог retention в днях от UpdatedAt (последней активности). > 0. |
BatchSize | int | Да | config (Favorites:GuestRetention:BatchSize, default 1000) | Размер батча удаления за одну транзакцию. > 0. |
Cron | string | Да | config (Favorites:GuestRetention:Cron, default 0 3 * * *) | Расписание суточного запуска (UTC). |
MaxRunDuration | TimeSpan | Да | 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 на уровне джобы).
- Контекст. Суточный тик; actor = system,
correlationId = jobRunId (генерируется на прогон),
scope — системный (вне tenant). Идемпотентность обеспечивает сам предикат (шаг 5), а не ключ.
- Проверка входа. Внешних данных нет — проверяется только конфиг при старте (
Days > 0,
BatchSize > 0, валидный Cron) → иначе fail-fast хоста. Вычислить cutoff = now() − Days.
- Что читаем (под блокировкой). Выбрать только
Id протухших гостевых записей
(user_id IS NULL AND updated_at < cutoff), отсортированных по updated_at, не более BatchSize;
строки, уже взятые параллельным инстансом, пропускаются. Индекс ix_favorites_created_at /
updated_at обслуживает выборку и сортировку. Сущности в память не грузятся — Note/остальные поля
не читаются.
- Бизнес-правила. Переходов состояний нет: у
Favorite нет поля Status, жизненный цикл
заканчивается физическим удалением (soft delete не применяется — favorite.md).
Единственное правило — строгий guard user_id IS NULL: записи с user_id IS NOT NULL в выборку не
входят и не удаляются.
- Что меняется. Батч удаляется одним set-based
DELETE по предикату
user_id IS NULL AND updated_at < cutoff AND id ∈ выбранные — guard user_id IS NULL продублирован в
предикате удаления как defense-in-depth. Итерация: шаги 3→5 повторяются, пока батч полон; неполный
батч означает исчерпание — прогон завершается.
- События. Не публикуются. Kafka-событие не шлётся: retention — техническая чистка, не действие
пользователя; потребители
user-activity получают только факты add/remove/merge, в ActionEvent
retention не идёт. Доменное FavoriteRemovedEvent тоже не поднимается — это не пользовательское
удаление.
- Согласованность. Каждый батч — своя транзакция; долгий общий lock не берётся; зафиксированный
батч остаётся удалённым даже при сбое следующего. Внешних вызовов внутри транзакции нет (Kafka не
задействован).
- Результат. 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 work | per-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 |
Наблюдаемость
| Тип | Правило |
|---|
| Logs | job started (jobRunId, cutoff, RetentionDays); batch deleted (batchNo, rows); job completed (deletedTotal, batches, durationMs, backlogRemaining); job failed (jobRunId, batchNo, exception). AnonymousId/Note в логи не пишутся (приватные данные) |
| Metrics | favorites_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 после прогона) |
| Traces | span на прогон с jobRunId, атрибуты retentionDays, batchSize, deletedTotal; вложенные span на батч |
| Dashboard | favorites-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 по
времени).