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

Background Job: SnapshotDetachRetryJob

Назначение

SnapshotDetachRetryJob — фоновый worker сервиса news, гарантирующий detach иммутабельных SelectionSnapshot, которые перестали быть Active в блоках поста, но синхронный вызов selections после сохранения не удался (сеть/selections недоступен). Два источника «осиротевших» snapshot-ов (дом переходов — post.md, post-block.md):

  1. BlockReplaced — автор заменил блок-подборку целиком; порядок «создать → переключить → отвязать», старый snapshot должен уйти в Detached (решение №2 ../README.md).
  2. PostDeleted — пост удалён (soft, Published/Unpublished → Archived); все его Active-snapshot-ы отвязываются.

Модель надёжности — локальный action-outbox news.snapshot_detach_outbox: строка-намерение пишется в том же согласованном изменении, что set-blocks / delete-post; операция владельца делает первый detach синхронно best-effort сразу после сохранения, а этот job — сеть безопасности: вычитывает Pending, повторяет вызов selections POST /internal/snapshots/{id}/detach (идемпотентно, INV-SNP-005) с backoff, poison → карантин + алерт. Kafka здесь не применяется — связь PostId ↔ SnapshotId синхронная (решение №1), события pin.selections.* не существуют, поэтому стандартный Kafka-outbox непригоден.

MetadataЗначение
Owning teamPIN Engagement
Статус документаdraft
Область действияконтекст news, таблица news.snapshot_detach_outbox; внешний вызов — selections internal API (m2m)
Assumptions/ограниченияdetach иммутабельного snapshot-а идемпотентен и однократен (INV-SNP-005); selections restrict-хранит snapshot вечно (INV-SNP-006) — повтор безопасен неограниченно; happy-path-latency несёт синхронный вызов у владельца, job покрывает только сбойные записи (steady-state Pending ≈ 0)
ЗависимостиPostgreSQL (news); selections internal API (m2m); распределённая блокировка (PostgreSQL-backend — Redis нет, ADR-0056)

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

ПолеЗначение
Сервисnews
Тип jobRecurring (hosted background-service, внутренний таймер)
Triggerвнутренний таймер каждые PollingInterval (~5 мин, конфиг)
Критичностьmedium (консистентность истории/состава; недоступность не блокирует ленты/чтение, но копит Pending и осиротевшие Active-snapshot-ы)

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

АспектПравило
Scheduleкаждые PollingInterval (default 5 мин; UTC); backoff записи не зависит от периода (см. NextAttemptAt)
Distributed lockprimary — single-runner: ключ Pin:lock:job:scheduled:news-snapshot-detach-retry, TTL LockTtl (default 6 мин > MaxRunDuration); PostgreSQL advisory-backend (Redis нет). Если lock занят другим pod — прогон пропускается, без retry
Row-leveldefense-in-depth: claim-выборка под блокировкой строк с пропуском занятых (FOR UPDATE SKIP LOCKED) — при лаге lock на failover два runner-а берут непересекающиеся строки; двойной detach всё равно идемпотентен (INV-SNP-005)
Batch sizeBatchSize строк за проход (default 100); прогон повторяет выборку, пока батч полон и не истёк MaxRunDuration
Max durationMaxRunDuration (default 4 мин) — предохранитель; остаток берёт следующий tick
Cancellationостановка хоста учитывается между записями; graceful stop, без обрыва claim-транзакции

Вход

Внешнего runtime-входа нет: поведение задаёт конфиг, рабочий вход — строки news.snapshot_detach_outbox.

Config SnapshotDetachRetryOptions (bind + валидация на старте)

ПолеТипОбязательностьИсточникОписание
PollingIntervalTimeSpanДаconfigПериод запуска (default 5 мин).
BatchSizeintДаconfigРазмер claim-выборки (> 0, default 100).
MaxRunDurationTimeSpanДаconfigВерхняя граница прогона (default 4 мин).
MaxAttemptsintДаconfigПорог poison → Quarantined (default 12).
BackoffBase / BackoffFactor / BackoffCapTimeSpan / double / TimeSpanДаconfigЭкспоненциальный backoff (default 1 мин / 2.0 / 30 мин) + jitter.
DetachCallTimeoutTimeSpanДаconfigТаймаут одного m2m-вызова selections (default 5 с).
LockTtlTimeSpanДаconfigTTL распределённой блокировки (default 6 мин).

Reliability-таблица SnapshotDetachOutboxEntry (news.snapshot_detach_outbox)

Операционная структура job-а (не сущность домена).

ПолеТипОбязательностьОграниченияОписание
IdGuidДаPK (Id версии 7)Идентификатор записи-намерения.
SnapshotIdGuidДаunique ux_sdo_snapshot where status <> 'Done'; логическая ссылка selections без FKОтвязываемый snapshot; dedupe-ключ (детач однократен).
PostIdGuidДаindex ix_sdo_postПост-источник (корреляция/лог).
ReasonSnapshotDetachReason (enum-string)Дахранится как text, varchar(16)BlockReplaced/PostDeleted; дом — selection-snapshot.md, здесь копия для detach-вызова.
AttemptsintДа>= 0, default 0Число выполненных попыток (инкремент при claim).
NextAttemptAtDateTimeOffsetДаindex ix_sdo_due (status, next_attempt_at)Расписание следующей попытки (backoff).
StatusSnapshotDetachOutboxStatus (enum-string)Дахранится как text, varchar(16), default PendingPending/Done/Quarantined.
LastErrorstring?Нетvarchar(512)Последняя ошибка (класс + код, без секретов/PII).
CreatedAt / UpdatedAtDateTimeOffsetДаШтампы; CreatedAt — база SLO-лага.

Конкурентный доступ claim/outcome — оптимистическая блокировка (claim не должен затирать outcome).

Enum SnapshotDetachOutboxStatus (varchar(16))

ЗначениеОписание
PendingОжидает/повторяет detach; берётся job-ом при NextAttemptAt <= now.
Doneselections подтвердил Detached (или уже был Detached — идемпотентно). Терминальный.
QuarantinedPoison: Attempts >= MaxAttempts; исключён из выборки, требует runbook + алерт.

Actor — system (worker), вне tenant (tenant несёт Post); публичного входа нет.

Алгоритм

Внешний вызов selectionsвне транзакции (claim и outcome — два коротких согласованных изменения, детач между ними), чтобы не держать row-lock/соединение на сетевом I/O.

  1. Контекст. Прогон под распределённой блокировкой; actor = system, correlationId = jobRunId, вне tenant. Идемпотентность обеспечивают статус-фильтр + NextAttemptAt + идемпотентный detach (INV-SNP-005).
  2. Проверка входа. Внешних данных нет; конфиг проверен на старте (BatchSize > 0, MaxAttempts > 0, BackoffBase > 0, иначе fail-fast хоста).
  3. Claim под блокировкой. Если lock занят — прогон пропускается. Иначе цикл батчей до пустоты/ MaxRunDuration: берётся страница записей Status = Pending и NextAttemptAt <= now, по возрастанию NextAttemptAt (индекс ix_sdo_due), под блокировкой строк с пропуском занятых (FOR UPDATE SKIP LOCKED, разводит строки параллельного runner-а).
  4. Claim-фаза (пометка попытки). Для каждой claimed-записи предварительно: Attempts += 1, NextAttemptAt = now + backoff(Attempts) (min(BackoffBase · BackoffFactor^(Attempts−1), BackoffCap) + jitter). Пред-инкремент — безопасное направление: краш до outcome даёт повтор после backoff, а не вечный цикл. Claim фиксируется отдельным согласованным изменением — снимает row-lock: записи «лизнуты».
  5. Detach + outcome. На каждую запись — вызов internal API selections POST /internal/snapshots/{SnapshotId}/detach { reason } (m2m, таймаут DetachCallTimeout). Затем запись обновляется:
    • успех / уже Detached (2xx, INV-SNP-005) → Status = Done, LastError = null;
    • transient (5xx/timeout/сеть) → остаётся Pending (backoff уже назначен в шаге 4), LastError = код;
    • poison (Attempts >= MaxAttempts) → Status = Quarantined + доменное событие SnapshotDetachQuarantinedEvent (внутренний алерт).
  6. События. Интеграционных Kafka-событий нет (детач синхронный). Доменное SnapshotDetachQuarantinedEvent ({ SnapshotId, PostId, Reason, Attempts }) — только внутренний алерт/метрика, вне claim-транзакции.
  7. Согласованность. Claim-фаза (шаг 4) и outcome (шаг 5) — два отдельных коротких согласованных изменения, между ними — сетевой вызов без открытой транзакции.
  8. Результат. Наружу — метрики прогона (SnapshotId, Status, Attempts — для лога/метрик); инфраструктурный сбой на записи логируется и не рвёт прогон (частичный прогресс закоммичен).

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

Предусловия. Строка-намерение SnapshotDetachOutboxEntry (Status = Pending) записана в том же согласованном изменении, что set-blocks/delete-post (enqueue at source — post.md, post-block.md); прогон идёт под распределённой блокировкой; конфиг валиден. Внешний selections internal API либо достижим (detach проходит), либо недоступен (записи копятся в Pending с backoff).

Постусловия (после обработки записи):

  • успех / уже Detached (2xx, INV-SNP-005) → Status = Done, LastError = null; соответствующий SelectionSnapshot в selectionsDetached.
  • transient-сбой → запись остаётся Pending с назначенным NextAttemptAt (экспоненциальный backoff), Attempts инкрементирован; следующий tick повторит.
  • poison (Attempts >= MaxAttempts) → Status = Quarantined + SnapshotDetachQuarantinedEvent (внутренний алерт); запись исключена из выборки до ручного re-enqueue.
  • Гарантия консистентности состава: осиротевший Active-snapshot заменённого (BlockReplaced) или удалённого (PostDeleted) поста уходит в Detached не позднее суммарного backoff-окна — либо попадает в Quarantined для ручного разбора; steady-state backlog Pending ≈ 0 вне инцидента selections.
  • Иммутабельность истории: snapshot иммутабелен и не удаляется (INV-SNP-006) — старые PostVersion продолжают рендерить исторический состав независимо от факта detach; detach лишь снимает пометку Active у владельца.
  • Идемпотентность: повторный detach уже-Detached snapshot-а — no-op success; рестарты и N параллельных инстансов повторного эффекта не дают.

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

ЭлементПравило
АтомарностьДва коротких изменения на батч: claim (Attempts/NextAttemptAt) и outcome (Status/LastError); enqueue-строка пишется в изменении владельца (set-blocks/delete-post — post.md)
Сохраняемое состояниеnews.snapshot_detach_outbox (Status/Attempts/NextAttemptAt/LastError)
External callsselections detach — между claim и outcome, вне открытой транзакции (нет row-lock/соединения на сетевом I/O); тайм-аут DetachCallTimeout
Частичная ошибкаcontinue: сбой на одной записи логируется, не откатывает соседние; запись остаётся Pending (backoff) или Quarantined; следующий tick/прогон повторяет

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

СценарийПравило
Job restartCheckpoint не нужен — прогресс = сам Status; рестарт заново фильтрует Pending по NextAttemptAt
Параллельные инстансыРаспределённая блокировка → один активный runner; при лаге на failover пропуск занятых строк разводит строки, двойной detach идемпотентен (INV-SNP-005)
Дубликат записиux_sdo_snapshot (where status <> 'Done') + enqueue по SnapshotId → одна активная запись на snapshot
Повторяемая ошибкаselections недоступен → Pending + экспоненциальный backoff (BackoffBase·Factor^n, cap BackoffCap, jitter); pre-increment Attempts при claim
Poison itemAttempts >= MaxAttemptsQuarantined + алерт; исключён из выборки, разбор по runbook
Идемпотентность detachselections для уже-Detached snapshot-а отвечает no-op success (INV-SNP-005) → Status = Done без повторного эффекта

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

ТипПравило
Logsjob started (jobRunId); batch claimed (rows); entry detached (snapshotId, attempts); entry retry (snapshotId, attempts, errorCode); entry quarantined (snapshotId, reason); job completed (succeeded, retried, quarantined, durationMs). SnapshotId/PostId — не PII
Metricsnews_snapshot_detach_pending (gauge — Pending-backlog); news_snapshot_detach_oldest_age_seconds (gauge — now − min(CreatedAt) по Pending); news_snapshot_detach_retried_total; news_snapshot_detach_succeeded_total; news_snapshot_detach_failed_total; news_snapshot_detach_quarantined_total; news_snapshot_detach_lag_seconds (histogram — now − CreatedAt на Done)
Tracesspan на прогон (jobRunId), вложенный span на попытку detach (snapshotId, attempts); корреляция с внешним вызовом selections
Alertsрост pending/oldest_age_seconds (job/selections не справляется); quarantined_total > 0; всплеск failed_total / 5xx клиента selections (связка с README «рост 5xx selections-клиента»); отсутствие успешного прогона > 15 мин

SLO

ПоказательЦельОбоснование
Лаг detachp99 ≤ 2 × PollingInterval (≈ 10 мин) при доступном selectionshappy-path — синхронно; сбойные — ближайший backoff-цикл
Отсутствие «вечных» Pendingнет Pending старше суммарного backoff-окна без перехода в Quarantinedметрика oldest_age_seconds + алерт
Steady-state backlognews_snapshot_detach_pending ≈ 0 вне инцидента selectionsjob — сеть безопасности, не основной путь
Идемпотентность0 повторных эффектов detach при рестартах/N инстансахфильтр + NextAttemptAt + INV-SNP-005

Связанный Runbook

СценарийRunbook
pending/oldest_age растётпроверить доступность selections internal API и 5xx клиента; после восстановления backlog разгребётся сам
quarantined_total > 0разобрать LastError; при устранимой причине — ручной re-enqueue (Quarantined → Pending, Attempts = 0); сверка SnapshotId блока ↔ snapshot в selections (README «пост показывает не тот состав»)
Worker стоит (нет прогонов)проверить hosted-service/pod, PollingInterval, распределённую блокировку (застрявший держатель)

Тесты

ТестОжидание
Успешный detachPending-запись → вызов selections 2xx → Status = Done, метрика succeeded_total++, snapshot Detached
Пустой прогоннет Pending с NextAttemptAt <= now — no-op success, вызовов нет
Недоступность selections → повтор5xx/timeout → запись остаётся Pending, Attempts++, NextAttemptAt сдвинут по backoff; следующий прогон повторяет
Идемпотентностьsnapshot уже Detachedselections no-op success → Status = Done без повторного эффекта (INV-SNP-005)
Poison → карантинAttempts достигает MaxAttemptsStatus = Quarantined + SnapshotDetachQuarantinedEvent/алерт; исключён из выборки
Dedupe по SnapshotIdповторный enqueue того же snapshot-а не создаёт вторую активную запись (ux_sdo_snapshot)
Параллельные инстансыраспределённая блокировка → один runner; при обходе — пропуск занятых строк не даёт двойного claim одной строки
Cancellationотмена между записями — graceful stop, claim-транзакция не рвётся, закоммиченное сохранено

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

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

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

  • Дом переходов/полей: post.md (DeletePostDeleted), post-block.md (SelectionEmbed, замена блока → BlockReplaced), selection-snapshot.md (INV-SNP-005/006, Detach, SnapshotDetachReason, endpoint /internal/snapshots/{id}/detach).
  • Обзор/эксплуатация: ../README.md («Background jobs», «Alerts», «Runbooks», решения №1/№2); соседний job — comment-count-reconcile.
  • Прецедент паттерна (single-runner + пропуск занятых строк): ../../user-activity/background/activity-closer.md.
  • Решения: ADR-0052 (вне tenant), ADR-0056 (PostgreSQL-only, Redis нет).