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 team | PIN 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 |
| Тип job | Recurring (hosted background-service + cron) |
| Trigger | cron 0 3 * * * (Europe/Moscow) ежедневно; FullSweep — по FullSweepCron (напр. воскр.), иначе DailyWindow |
| Критичность | low (косметический счётчик ленты; деградация не влияет на чтение постов/комментариев) |
Расписание И Locking
| Аспект | Правило |
|---|
| Schedule | cron 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 size | BatchSize постов на итерацию (default 500); keyset-цикл, пока батч полон и не превышен MaxRunDuration |
| Max duration | MaxRunDuration (default 20 мин) — предохранитель; остаток берёт следующий прогон от checkpoint.LastPostId |
| Cancellation | остановка хоста учитывается между батчами — graceful stop, закоммиченное сохранено |
Вход
Внешнего runtime-входа нет — параметры прогона формируются из CommentReconcileOptions (config, bind на
старте). Actor = system, вне tenant (скан по всем постам платформы — ADR-0052).
| Поле | Тип | Обязательность | Источник | Описание |
|---|
Scope | CommentReconcileScope (enum, string) | Да | config/расписание | DailyWindow (окно активности) / FullSweep (полный проход). |
WindowLookback | TimeSpan | Да (при DailyWindow) | config (default 26 ч) | Порог активности; запас над суткой закрывает лаг прошлого прогона. |
BatchSize | int | Да | config (> 0) | Число постов на батч. |
MaxRunDuration | TimeSpan | Да | config | Верхняя граница одного прогона. |
StartAfterPostId | Guid? | Нет | checkpoint | Keyset-курсор для resume; null — с начала. |
Результат прогона:
| Поле | Тип | Описание |
|---|
PostsReconciled | int | Сколько постов просканировано (сверено). |
DriftCorrected | int | Сколько постов имели дрейф и исправлены. |
BatchesProcessed | int | Число обработанных батчей. |
LastPostId | Guid? | Последний обработанный Post.Id (keyset). |
BacklogRemaining | bool | Прерван ли прогон по MaxRunDuration (остаток есть). |
DurationMs | long | Длительность прогона. |
Промежуточная типизированная проекция COUNT-запроса — PostVisibleCommentCount:
| Поле | Тип | Описание |
|---|
PostId | Guid | Пост. |
VisibleCount | int | Число комментариев со Status = Visible. |
Алгоритм
cutoff = now() − WindowLookback; порядок постов — Id asc (Id версии 7 даёт стабильный хронологический
keyset без отдельного ключа сортировки).
- Контекст. Прогон под распределённой блокировкой; actor = system,
correlationId = jobRunId, вне
tenant. Идемпотентность даёт абсолютный пересчёт (шаг 4), а не ключ.
- Проверка входа. Параметры config:
BatchSize > 0, MaxRunDuration > 0, WindowLookback > 0 при
Scope = DailyWindow (иначе прогон не стартует — ошибка конфигурации). Существование сущностей проверять
нечего — выборка сама отбирает состояние.
- Что читаем (под блокировкой). Курсор —
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).
- Бизнес-правила. Для каждого поста батча ожидаемое значение = число видимых комментариев (0, если нет);
если
CommentsCount != ожидаемое — счётчик правится (дрейф исправлен), иначе no-op. Статус/публикация/
версия поста/SearchText не трогаются — правится только счётчик.
- Что меняется. Правки
CommentsCount постов батча + продвижение checkpoint (LastPostId = last(batch).Id)
применяются в одном согласованном изменении на батч. Пересчёт денорм-счётчика доменного перехода не создаёт
и событий не публикует (счётчик — для отображения, ADR-0054). Цикл повторяется, пока батч полон и
elapsed < MaxRunDuration.
- Результат. Итоги прогона:
PostsReconciled, DriftCorrected, BatchesProcessed, LastPostId,
BacklogRemaining, DurationMs. Пустой батч — no-op success. Инфраструктурный сбой на батче логируется,
закоммиченное сохранено, остаток берёт следующий прогон от checkpoint.
Предусловия И Постусловия
Предусловия. Прогон стартует только под удержанной распределённой блокировкой; PostgreSQL (news) доступен;
конфиг валиден (BatchSize > 0, MaxRunDuration > 0, при DailyWindow — WindowLookback > 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 restart | Resume от checkpoint.LastPostId (keyset); уже сверённые посты в текущем окне не переобрабатываются в рамках прогона |
| Повторный прогон (idempotent) | Пересчёт абсолютный (счёт → присвоение), не дельта: второй прогон по тем же данным даёт DriftCorrected = 0 и тот же CommentsCount |
| Параллельные инстансы | Исключены распределённой блокировкой; дополнительно пропуск занятых строк (SKIP LOCKED) защитил бы от гонок |
| Гонка с inc/dec горячего пути | Пост, залоченный живым обработчиком, пропускается (SKIP LOCKED) → без потери апдейта; пересчёт возьмёт его следующим прогоном |
| Повторяемая ошибка | Инфраструктурный сбой — checkpoint не двинулся на сбойном батче; следующий прогон повторяет; без бесконечного retry внутри прогона |
Наблюдаемость
| Тип | Правило |
|---|
| Logs | job started (jobRunId, scope, cursor); batch processed (rows, corrected); drift corrected (postId, from→to) на уровне Debug; job completed (postsReconciled, driftCorrected, batches, backlogRemaining, durationMs); lock skipped. PII нет |
| Metrics | news_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 |
| Traces | span на прогон с 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
| Поле | Значение |
|---|
JobCode | news-comment-reconcile |
OwnerService | news |
Trigger | cron 0 3 * * * (MSK) |
IdempotencyKey | не требуется — абсолютный пересчёт идемпотентен по построению |
LockKey | Pin:lock:job:scheduled:news-comment-reconcile |
Checkpoint | CommentReconcileCheckpoint.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, закоммиченное сохранено |
Связанные Документы