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

Background Job: ActivityCloserJob

Назначение

ActivityCloserJob — фоновый polling-worker модуля sessions/views, который закрывает по неактивности открытые Session и PropertyObjectView: переводит Active → Inactive, считает Duration, ставит EndedAt и публикует session-ended / property-object-view-ended в outbox. Это реализация механизма закрытия ADR-0056 п.3 (simplicity-first: PostgreSQL-only, без Redis/TTL — вместо online-expiry периодический скан + построчная блокировка FOR UPDATE SKIP LOCKED).

Worker решает две задачи в одном прогоне:

  1. Протухшие сессииActive-сессии, у которых now() − LastActivityAt > InactivityThreshold: закрыть сессию и все её ещё открытые просмотры одной транзакцией (INV-SES-005, INV-LV-007).
  2. «Висящие» просмотрыActive-PropertyObjectView, чья сессия ещё Active (heartbeat сессии идёт), но по самому просмотру давно нет сигнала (now() − PropertyObjectView.LastActivityAt > InactivityThreshold): закрыть только просмотр, сессию не трогать (INV-LV-004).
MetadataЗначение
Owning teamPIN Engagement
Статус документаdraft
Область действияконтекст user-activity, таблицы user_activity.sessions / user_activity.property_object_views, проект Pin.UserActivity.Background
Assumptions/ограниченияDuration = LastActivityAt − CreatedAt неточна на границе цикла (не учитывает хвост после последнего heartbeat) — приемлемо для истории/агрегатов, точная сессионная аналитика — dev-stats (ADR-0057); целевая нагрузка 1–10k активных ⇒ тысячи открытых строк, партиционирования нет
ЗависимостиPostgreSQL (user_activity); Kafka (через outbox); события потребляют PropertyObjectViewEndedConsumer (self, property-object-view-stats.md), dev-stats, crm
Change contextgreenfield; заявлен в ../README.md («Background jobs») и domain-доках как будущий worker

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

ПолеЗначение
Сервисuser-activity
Тип jobRecurring (фоновый воркер, внутренний таймер)
Triggerвнутренний таймер каждые PollingInterval (~1 мин, конфиг)
Критичностьmedium (трекинг вне денежного пути; деградация задерживает историю/агрегаты, не блокирует каталог)

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

АспектПравило
Scheduleкаждые PollingInterval (default 1 мин; UTC); лаг закрытия ≤ InactivityThreshold + один polling-цикл
Distributed lockне требуется — конкурентность даёт построчная блокировка FOR UPDATE SKIP LOCKED: несколько инстансов берут непересекающиеся строки, дубль-закрытия нет (INV-SES-006, INV-LV-008); Redis отсутствует (ADR-0056). Опционально ограничить одним инстансом — PostgreSQL advisory lock Pin:lock:job:scheduled:activity-closer, но по построению он не нужен
Batch sizeBatchSize строк на выборку (default 500); прогон повторяет выборку, пока батч полон — большой backlog разгребается за несколько итераций
Max durationMaxRunDuration (default 5 мин) — предохранитель; при превышении прогон завершается штатно, остаток берёт следующий tick
Cancellationотмена таймера/хоста учитывается между записями — graceful stop, без обрыва открытой транзакции

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

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

ПолеТипОбязательностьИсточникОписание
InactivityThresholdTimeSpanДаconfig (ADR-0056 п.3)Порог неактивности 10–15 мин (default 12 мин). Общий для сессий и просмотров.
PollingIntervalTimeSpanДаconfigПериод запуска (~1 мин).
BatchSizeintДаconfigРазмер выборки строк за проход (> 0, default 500).
MaxRunDurationTimeSpanДаconfigВерхняя граница одного прогона (default 5 мин).

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

Алгоритм

Порог t = InactivityThreshold, отсечка cutoff = now() − t. Порядок фаз обязателен: сначала просмотры, затем сессии (INV-LV-007 — у Inactive-сессии не остаётся Active-просмотров).

  1. Контекст. Actor = system, correlationId = jobRunId (Guid v7 на прогон), вне tenant. Идемпотентность обеспечивается фильтром Status = Active + построчной блокировкой (шаг 3), а не ключом.
  2. Проверка. Внешних данных нет — проверяется только конфиг при старте (InactivityThreshold ∈ [10 мин, 15 мин], BatchSize > 0) → иначе fail-fast хоста. Существование/статус сущностей проверять нечего: выборка сама отбирает нужное состояние.
  3. Что читаем (под построчной блокировкой FOR UPDATE SKIP LOCKED, батч BatchSize).
    • Фаза A — висящие просмотры: Active-просмотры с LastActivityAt < cutoff, по LastActivityAt asc (индекс ix_property_object_views_active_last_activity). Для актуального UserId в payload — сессии этих просмотров батчем (без N+1; см. property-object-view.md «UserId — join в worker-е»).
    • Фаза B — протухшие сессии: Active-сессии с LastActivityAt < cutoff, по LastActivityAt asc (индекс ix_sessions_active_last_activity); затем на каждую — её оставшиеся Active-просмотры (под той же блокировкой). SKIP LOCKED пропускает строки, взятые параллельным инстансом/предыдущим незакоммиченным прогоном.
  4. Бизнес-правила и переходы (дом полей — session.md/property-object-view.md, здесь только маппинг):
    • закрытие просмотра: guard Status == Active; Status: Active → Inactive; EndedAt = now(); Duration = LastActivityAt − CreatedAt (check chk_property_object_views_duration_nonneg); фиксируется факт PropertyObjectViewEnded.
    • закрытие сессии: guard Status == Active; Status: Active → Inactive; EndedAt = now(); Duration = LastActivityAt − CreatedAt; предварительно закрываются все оставшиеся Active-просмотры сессии (тем же правилом); фиксируется факт SessionEnded.
  5. Что меняется. Закрытые (переведённые в Inactive) property_object_views и sessions. Порядок с учётом FK PropertyObjectView.SessionId → sessions (Restrict): просмотр закрывается раньше/вместе со своей сессией.
  6. Какие события фиксируются (через транзакционный outbox тем же согласованным изменением):
    • на каждый закрытый PropertyObjectViewPropertyObjectViewEndedKafkaEvent (ContractVersion = 1, topic pin.user-activity.property-object-view-ended.v1, AggregateType = "PropertyObjectView"); payload/поля — дом property-object-view.md (PropertyObjectViewId, PropertyObjectId, SessionId, AnonymousId, UserId? из join, ViewSource, ViewedAt = CreatedAt, LastActivityAt, DurationSeconds, FilterSnapshot?);
    • на каждую закрытую SessionSessionEndedKafkaEvent (ContractVersion = 1, topic pin.user-activity.session-ended.v1); поля — дом session.md (SessionId, AnonymousId, UserId?, CreatedAt, LastActivityAt, DurationSeconds). Device.IpAddress/UserAgent в payload не включаются.
  7. Согласованность. Фаза A — одно согласованное изменение на батч висящих просмотров; фаза B — одно согласованное изменение на сессию (сессия + её просмотры + весь outbox атомарно, INV-SES-005); outbox-publisher доставит события в Kafka после коммита.
  8. Результат. Job возвращает не наружу, а метрики прогона (закрыто сессий/просмотров, длительность, остался ли backlog). Пустой прогон — no-op success. Инфраструктурная ошибка на записи — лог + переход к следующей (частичный прогресс закоммичен), остаток берёт следующий tick.

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

ЭлементПравило
Unit of workФаза A — per-batch (висящие просмотры); фаза B — per-session (сессия + её просмотры + outbox)
Сохраняемое состояниеsessions (Status/EndedAt/Duration), property_object_views (то же), записи outbox (session-ended/property-object-view-ended)
External callsВнутри транзакции внешних вызовов нет; публикация в Kafka — after-commit через outbox-publisher (at-least-once)
Частичная ошибкаcontinue: сбой на одной сессии/батче логируется и не откатывает уже закоммиченные; строка остаётся Active и повторно берётся следующим tick (SKIP LOCKED снимет lock после rollback)

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

СценарийПравило
Job restartCheckpoint не нужен — «прогресс» это сам факт Status = Inactive; рестарт заново фильтрует Active по cutoff, уже закрытые не попадают (INV-SES-006, INV-LV-008)
Параллельные инстансыБезопасны: FOR UPDATE SKIP LOCKED разводит строки; одну сессию/просмотр закрывает ровно один инстанс
Duplicate itemНевозможно в рамках прогона: выбираются только Active; повторный проход по той же строке даёт 0 совпадений
Повторяемая ошибкаИнфраструктурный сбой при фиксации изменений — строка не закрыта, откат снимает lock, следующий tick повторяет; без бесконечного retry внутри прогона
Дубликат событияPayload property-object-view-ended дедуплицирует потребитель по PropertyObjectViewId (INV-LVS-003); outbox гарантирует at-least-once, не exactly-once

Пред/постусловия И Согласование С Онлайн-операциями

Предусловие прогона: есть Active-строки; конфиг валиден (InactivityThreshold ∈ [10 мин, 15 мин], BatchSize > 0). Постусловие: в обработанных батчах не остаётся Active-строк старше cutoff; на каждую закрытую строку в outbox лежит событие; частичный/прерванный прогон оставляет незакрытые строки для следующего tick (прогресс = сам факт Status = Inactive).

Гонка со встречными онлайн-операциями за одну строку (worker держит построчную блокировку выбранной строки, онлайн-команды сериализуются за ним) — бизнес-исход детерминирован:

  • Heartbeat vs закрытие сессии/просмотра. Если worker закрыл первым, встречный session-heartbeat видит 0 Active-строк → авто-рестарт (новая сессия, INV-SES-003), а property-object-view-heartbeat возвращает Accepted = false → клиент переоткрывает просмотр (INV-LV-003). Если heartbeat успел первым — LastActivityAt сдвинулся за cutoff, и строка в этот прогон не выбирается. Двойного закрытия и потери активности нет.
  • link-session (Switched) vs закрытие. Обе операции закрывают одну сессию под построчной блокировкой: сериализуются, закрывает ровно одна сторона (guard Status == Active), вторая видит уже Inactive и не дублирует переход (INV-SES-006). Если worker закрыл сессию перед linklink не находит Active-сессии устройства → USER_ACTIVITY_SESSION_NOT_FOUND (клиент вызывает start-session).
  • Проигравший всегда наблюдает согласованное конечное состояние (Inactive + событие ушло ровно раз); повторный расчёт Duration или второй session-ended/property-object-view-ended на ту же строку исключён.

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

ТипПравило
Logsjob started (jobRunId, cutoff); batch processed (фаза, rows, closed); item failed (id, тип, exception); job completed (closedSessions, closedViews, durationMs, backlogRemaining). AnonymousId/IpAddress в логи не пишутся (PII)
Metricsactivity_closer_run_duration_ms (histogram); activity_closer_sessions_closed_total / _views_closed_total (counter); activity_closer_batch_backlog (gauge — осталось после прогона); activity_closer_errors_total; activity_closer_close_lag_seconds (now − LastActivityAt на закрытии — контроль SLO)
Tracesspan на прогон с jobRunId, атрибуты phase, batchSize, closedCount; вложенные span на транзакцию сессии
Alerts«залипший backlog» — Active-строки с LastActivityAt старше 2 × InactivityThreshold (worker не успевает/стоит); activity_closer_errors_total растёт; отсутствие успешного прогона > 5 мин; outbox backlog > 1000 (см. ../README.md «Alerts»)

SLO Точности

ПоказательЦельОбоснование
Лаг закрытияInactivityThreshold + PollingInterval (≈ 11–16 мин) для 99% строкpolling-delta ADR-0056; проверяется activity_closer_close_lag_seconds
Отсутствие «вечных» Activeнет Active с LastActivityAt старше 2 × InactivityThresholdалерт + метрика backlog
Погрешность Durationнедоучёт ≤ PollingInterval (хвост после последнего heartbeat)документированное упрощение; точная аналитика — dev-stats
Идемпотентность0 повторных закрытий/дублей доменного перехода при рестартах и N инстансахфильтр Active + FOR UPDATE SKIP LOCKED
Доставка событийat-least-once на каждый закрытый агрегат; дедуп у потребителяoutbox + PropertyObjectViewId-идемпотентность консюмера

Связанный Runbook

СценарийRunbook
Backlog растёт (worker не успевает)увеличить BatchSize/частоту, проверить блокировки PostgreSQL и лаг outbox-publisher
Worker стоит (нет прогонов)проверить hosted-service/pod, конфиг PollingInterval, advisory lock (если включён)
Массовый рост errors_totalпроверить доступность PostgreSQL/схемы, откаты транзакций; строки останутся Active и закроются после восстановления

Тесты

ТестОжидание
Empty batchнет Active старше cutoff — no-op success, событий нет
Протухшая сессия с открытыми просмотрамисессия Inactive, все её просмотры Inactive, Duration посчитан, outbox session-ended + property-object-view-ended на каждый (INV-LV-007)
Висящий просмотр в живой сессиипросмотр Inactive + property-object-view-ended; сессия остаётся Active
Идемпотентность/рестартповторный прогон по тем же данным закрывает 0 строк, дублей событий нет
Параллельные инстансыдве копии worker-а не закрывают одну строку дважды (FOR UPDATE SKIP LOCKED)
UserId в payloadproperty-object-view-ended.UserId = актуальный Session.UserId (сессия связана после открытия просмотра)
Cancellationотмена прогона между записями — graceful stop, открытая транзакция не рвётся, закоммиченное сохранено
Партиал-фейлсбой фиксации на одной сессии не откатывает соседние; сбойная остаётся Active

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

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

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