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

Background Job: CommentCountReconcileJob

Назначение

CommentCountReconcileJob — ночной reconciliation-worker сервиса news, который пересчитывает денормализованный счётчик Post.CommentsCount по фактическому числу видимых комментариев и правит расхождения (дрейф). Счётчик поддерживается инкрементально в горячем пути (INV-CMT-005: ++ при создании Visible, -- при DeletedByAuthor/HiddenByModerator — дом comment.md); из-за сбоев, гонок или ручных правок он может дрейфовать. Job — страховка: приводит CommentsCount к «истине» — числу комментариев со статусом Visible (enum CommentStatus.Visible — дом comment.md).

Счётчик — только для отображения/сортировки ленты (ADR-0054), поэтому пересчёт идёт вне денежного пути, ночным расписанием и не рассылает событий.

MetadataЗначение
Owning teamPIN Engagement
Статус документаdraft
Область действияконтекст news, таблицы news.posts / news.comments
Assumptions/ограниченияCommentsEnabled=false не обнуляет счётчик (старые Visible остаются видимыми — INV-NWS-006 гейтит только создание); дрейф ожидаемо мал (единицы на пост) — целевой профиль ADR-0056 (≤1 млн users, 1–10k активных); партиционирования нет
ЗависимостиPostgreSQL (news); распределённая блокировка (ADR-0042); Kafka не задействован (пересчёт событий не публикует)

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

ПолеЗначение
Сервисnews
Тип jobRecurring (hosted background-service + cron)
Triggercron 0 3 * * * (Europe/Moscow) ежедневно; FullSweep — по FullSweepCron (напр. воскр.), иначе DailyWindow
Критичностьlow (косметический счётчик ленты; деградация не влияет на чтение постов/комментариев)

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

АспектПравило
Schedulecron 0 3 * * * (03:00 MSK); DailyWindow — посты с активностью за WindowLookback; FullSweep — полный проход (реже, FullSweepCron)
Distributed lockодин pod на прогон под ключом Pin:lock:job:scheduled:news-comment-reconcile; ttl = LockTtl (default 30 мин, > MaxRunDuration). Если lock занят другим pod — лог «skipped, lock held» и выход (не ждать)
Row-levelвнутри прогона батч постов берётся под блокировкой строк с пропуском уже занятых (FOR UPDATE SKIP LOCKED) — согласованность с конкурентными inc/dec горячего пути + пропуск строк, взятых на запись (см. «Алгоритм» шаг 3)
Batch sizeBatchSize постов на итерацию (default 500); keyset-цикл, пока батч полон и не превышен MaxRunDuration
Max durationMaxRunDuration (default 20 мин) — предохранитель; остаток берёт следующий прогон от checkpoint.LastPostId
Cancellationостановка хоста учитывается между батчами — graceful stop, закоммиченное сохранено

Вход

Внешнего runtime-входа нет — параметры прогона формируются из CommentReconcileOptions (config, bind на старте). Actor = system, вне tenant (скан по всем постам платформы — ADR-0052).

ПолеТипОбязательностьИсточникОписание
ScopeCommentReconcileScope (enum, string)Даconfig/расписаниеDailyWindow (окно активности) / FullSweep (полный проход).
WindowLookbackTimeSpanДа (при DailyWindow)config (default 26 ч)Порог активности; запас над суткой закрывает лаг прошлого прогона.
BatchSizeintДаconfig (> 0)Число постов на батч.
MaxRunDurationTimeSpanДаconfigВерхняя граница одного прогона.
StartAfterPostIdGuid?НетcheckpointKeyset-курсор для resume; null — с начала.

Результат прогона:

ПолеТипОписание
PostsReconciledintСколько постов просканировано (сверено).
DriftCorrectedintСколько постов имели дрейф и исправлены.
BatchesProcessedintЧисло обработанных батчей.
LastPostIdGuid?Последний обработанный Post.Id (keyset).
BacklogRemainingboolПрерван ли прогон по MaxRunDuration (остаток есть).
DurationMslongДлительность прогона.

Промежуточная типизированная проекция COUNT-запроса — PostVisibleCommentCount:

ПолеТипОписание
PostIdGuidПост.
VisibleCountintЧисло комментариев со Status = Visible.

Алгоритм

cutoff = now() − WindowLookback; порядок постов — Id asc (Id версии 7 даёт стабильный хронологический keyset без отдельного ключа сортировки).

  1. Контекст. Прогон под распределённой блокировкой; actor = system, correlationId = jobRunId, вне tenant. Идемпотентность даёт абсолютный пересчёт (шаг 4), а не ключ.
  2. Проверка входа. Параметры config: BatchSize > 0, MaxRunDuration > 0, WindowLookback > 0 при Scope = DailyWindow (иначе прогон не стартует — ошибка конфигурации). Существование сущностей проверять нечего — выборка сама отбирает состояние.
  3. Что читаем (под блокировкой). Курсор — StartAfterPostId. Батч постов берётся под блокировкой строк с пропуском занятых (FOR UPDATE SKIP LOCKED), по возрастанию Id, размером BatchSize:
    • FullSweep: посты со Status != Archived и Id > cursor;
    • DailyWindow: то же + наличие комментария поста, изменённого не раньше cutoff (создание/скрытие/ удаление комментария двигают CreatedAt/UpdatedAt — дом comment.md). Пропуск занятых строк исключает конфликт с живым inc/dec горячего пути (INV-CMT-005) — их пересчитает следующий прогон; блокировки трафика нет. Затем — батч-COUNT видимых комментариев по постам батча (Status = Visible, группировка по PostId) → карта PostId → VisibleCount (отсутствие поста = 0).
  4. Бизнес-правила. Для каждого поста батча ожидаемое значение = число видимых комментариев (0, если нет); если CommentsCount != ожидаемое — счётчик правится (дрейф исправлен), иначе no-op. Статус/публикация/ версия поста/SearchText не трогаются — правится только счётчик.
  5. Что меняется. Правки CommentsCount постов батча + продвижение checkpoint (LastPostId = last(batch).Id) применяются в одном согласованном изменении на батч. Пересчёт денорм-счётчика доменного перехода не создаёт и событий не публикует (счётчик — для отображения, ADR-0054). Цикл повторяется, пока батч полон и elapsed < MaxRunDuration.
  6. Результат. Итоги прогона: PostsReconciled, DriftCorrected, BatchesProcessed, LastPostId, BacklogRemaining, DurationMs. Пустой батч — no-op success. Инфраструктурный сбой на батче логируется, закоммиченное сохранено, остаток берёт следующий прогон от checkpoint.

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

Предусловия. Прогон стартует только под удержанной распределённой блокировкой; PostgreSQL (news) доступен; конфиг валиден (BatchSize > 0, MaxRunDuration > 0, при DailyWindowWindowLookback > 0). Post.CommentsCount поддерживается инкрементально в горячем пути (INV-CMT-005) и может дрейфовать из-за сбоев/гонок/ручных правок.

Постусловия (после успешной сверки поста в scope прогона):

  • Post.CommentsCount равен числу комментариев поста со Status = Visible (пол — 0, если видимых нет); HiddenByModerator/DeletedByAuthor не учитываются.
  • Затронуты только CommentsCount и audit UpdatedAt; Status/публикация/CurrentVersionNumber/SearchText поста неизменны; ни доменных, ни интеграционных событий не ушло.
  • checkpoint.LastPostId продвинут до последнего обработанного поста; при обрыве по MaxRunDuration остаток гарантированно берёт следующий прогон от checkpoint.
  • Пост, залоченный конкурентным inc/dec горячего пути, пропущен (SKIP LOCKED) — апдейт не потерян, пост сверит следующий прогон.
  • Что видит пользователь: между прогонами счётчик ленты/карточки может отставать в пределах дрейфа; сходимость гарантирована ≤ 24 ч для постов из окна активности (DailyWindow) и ≤ FullSweepCron для «тихих» постов.

Граница Согласованности

ЭлементПравило
Атомарностьper-batch: правки CommentsCount постов батча + продвижение checkpoint — одно согласованное изменение
Сохраняемое состояниеnews.posts (только CommentsCount + audit UpdatedAt); CommentReconcileCheckpoint (LastPostId)
External callsнет (Kafka/HTTP не задействованы); COUNT — тот же PostgreSQL в границе прогона
Частичная ошибкаcontinue: сбой батча логируется, не откатывает предыдущие; checkpoint указывает на последний успешный, остаток берёт следующий прогон

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

СценарийПравило
Job restartResume от checkpoint.LastPostId (keyset); уже сверённые посты в текущем окне не переобрабатываются в рамках прогона
Повторный прогон (idempotent)Пересчёт абсолютный (счёт → присвоение), не дельта: второй прогон по тем же данным даёт DriftCorrected = 0 и тот же CommentsCount
Параллельные инстансыИсключены распределённой блокировкой; дополнительно пропуск занятых строк (SKIP LOCKED) защитил бы от гонок
Гонка с inc/dec горячего путиПост, залоченный живым обработчиком, пропускается (SKIP LOCKED) → без потери апдейта; пересчёт возьмёт его следующим прогоном
Повторяемая ошибкаИнфраструктурный сбой — checkpoint не двинулся на сбойном батче; следующий прогон повторяет; без бесконечного retry внутри прогона

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

ТипПравило
Logsjob started (jobRunId, scope, cursor); batch processed (rows, corrected); drift corrected (postId, from→to) на уровне Debug; job completed (postsReconciled, driftCorrected, batches, backlogRemaining, durationMs); lock skipped. PII нет
Metricsnews_comment_reconcile_posts_reconciled_total (counter); news_comment_reconcile_drift_corrected_total (counter); news_comment_reconcile_run_duration_ms (histogram); news_comment_reconcile_backlog_remaining (gauge); news_comment_reconcile_errors_total
Tracesspan на прогон с jobRunId, атрибуты scope, batchSize; вложенные span на батч
Alertsбольшой дрейфdrift_corrected_total за прогон > DriftAlertThreshold (сигнал о баге инкремента в горячем пути, а не о разовой правке); отсутствие успешного прогона > 26 ч; рост errors_total (см. ../README.md «Alerts» — drift CommentsCount)

SLO Точности

ПоказательЦельОбоснование
Свежесть счётчикадрейф исправляется ≤ 24 ч (следующий ночной прогон) для постов из окна активностиDailyWindow + WindowLookback запас
Полная сходимостьFullSweep устраняет дрейф на «тихих» постах (без свежих комментариев) не реже FullSweepCronокно не покрывает старые посты
Идемпотентность0 повторных правок при рестартах/повторных прогонах на неизменных данныхабсолютный пересчёт + keyset checkpoint
Влияние на live-трафик0 блокировок пользовательских комментариевпропуск занятых строк + ночное окно

Job Registry

ПолеЗначение
JobCodenews-comment-reconcile
OwnerServicenews
Triggercron 0 3 * * * (MSK)
IdempotencyKeyне требуется — абсолютный пересчёт идемпотентен по построению
LockKeyPin:lock:job:scheduled:news-comment-reconcile
CheckpointCommentReconcileCheckpoint.LastPostId (keyset по Post.Id)
FreshnessSlaдрейф окна ≤ 24 ч; полная сходимость ≤ FullSweepCron
Runbookсм. ниже

Связанный Runbook

СценарийRunbook
Всплеск drift_correctedискать баг инкремента INV-CMT-005 в горячем пути (create/delete/moderate комментария); reconcile лечит симптом, не причину
backlog_remaining не спадаетувеличить BatchSize/MaxRunDuration, проверить блокировки PostgreSQL и объём DailyWindow
Job не запускаетсяпроверить hosted-service/pod, cron, удержание распределённой блокировки (зависший держатель / истёкший ttl)

Тесты

ТестОжидание
Empty batchнет постов в окне (или все счётчики верны) — no-op success, DriftCorrected = 0
Drift исправляетсяпост с CommentsCount = 5, но 3 Visible → после прогона CommentsCount = 3, DriftCorrected = 1
Идемпотентность/повторный запускповторный прогон по тем же данным правит 0 постов, значения не меняются
Скрытые/удалённые не считаютсяHiddenByModerator/DeletedByAuthor не входят в счёт — счётчик равен числу Visible
Гонка с live-обработчикомпост, залоченный конкурентным inc/dec, пропущен (SKIP LOCKED), апдейт не потерян
Checkpoint/resumeпрерывание по MaxRunDuration → следующий прогон стартует от LastPostId, не с начала
Lock contentionвторой инстанс не стартует прогон при удержанном lock
Cancellationотмена между батчами — graceful stop, закоммиченное сохранено

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