Background Job: SnapshotDetachRetryJob
Назначение
SnapshotDetachRetryJob — фоновый worker сервиса news, гарантирующий detach иммутабельных
SelectionSnapshot, которые перестали быть Active в
блоках поста, но синхронный вызов selections после сохранения не удался (сеть/selections недоступен).
Два источника «осиротевших» snapshot-ов (дом переходов — post.md,
post-block.md):
BlockReplaced — автор заменил блок-подборку целиком; порядок «создать → переключить → отвязать»,
старый snapshot должен уйти в Detached (решение №2 ../README.md).
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 team | PIN 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 |
| Тип job | Recurring (hosted background-service, внутренний таймер) |
| Trigger | внутренний таймер каждые PollingInterval (~5 мин, конфиг) |
| Критичность | medium (консистентность истории/состава; недоступность не блокирует ленты/чтение, но копит Pending и осиротевшие Active-snapshot-ы) |
Расписание И Locking
| Аспект | Правило |
|---|
| Schedule | каждые PollingInterval (default 5 мин; UTC); backoff записи не зависит от периода (см. NextAttemptAt) |
| Distributed lock | primary — single-runner: ключ Pin:lock:job:scheduled:news-snapshot-detach-retry, TTL LockTtl (default 6 мин > MaxRunDuration); PostgreSQL advisory-backend (Redis нет). Если lock занят другим pod — прогон пропускается, без retry |
| Row-level | defense-in-depth: claim-выборка под блокировкой строк с пропуском занятых (FOR UPDATE SKIP LOCKED) — при лаге lock на failover два runner-а берут непересекающиеся строки; двойной detach всё равно идемпотентен (INV-SNP-005) |
| Batch size | BatchSize строк за проход (default 100); прогон повторяет выборку, пока батч полон и не истёк MaxRunDuration |
| Max duration | MaxRunDuration (default 4 мин) — предохранитель; остаток берёт следующий tick |
| Cancellation | остановка хоста учитывается между записями; graceful stop, без обрыва claim-транзакции |
Вход
Внешнего runtime-входа нет: поведение задаёт конфиг, рабочий вход — строки news.snapshot_detach_outbox.
Config SnapshotDetachRetryOptions (bind + валидация на старте)
| Поле | Тип | Обязательность | Источник | Описание |
|---|
PollingInterval | TimeSpan | Да | config | Период запуска (default 5 мин). |
BatchSize | int | Да | config | Размер claim-выборки (> 0, default 100). |
MaxRunDuration | TimeSpan | Да | config | Верхняя граница прогона (default 4 мин). |
MaxAttempts | int | Да | config | Порог poison → Quarantined (default 12). |
BackoffBase / BackoffFactor / BackoffCap | TimeSpan / double / TimeSpan | Да | config | Экспоненциальный backoff (default 1 мин / 2.0 / 30 мин) + jitter. |
DetachCallTimeout | TimeSpan | Да | config | Таймаут одного m2m-вызова selections (default 5 с). |
LockTtl | TimeSpan | Да | config | TTL распределённой блокировки (default 6 мин). |
Reliability-таблица SnapshotDetachOutboxEntry (news.snapshot_detach_outbox)
Операционная структура job-а (не сущность домена).
| Поле | Тип | Обязательность | Ограничения | Описание |
|---|
Id | Guid | Да | PK (Id версии 7) | Идентификатор записи-намерения. |
SnapshotId | Guid | Да | unique ux_sdo_snapshot where status <> 'Done'; логическая ссылка selections без FK | Отвязываемый snapshot; dedupe-ключ (детач однократен). |
PostId | Guid | Да | index ix_sdo_post | Пост-источник (корреляция/лог). |
Reason | SnapshotDetachReason (enum-string) | Да | хранится как text, varchar(16) | BlockReplaced/PostDeleted; дом — selection-snapshot.md, здесь копия для detach-вызова. |
Attempts | int | Да | >= 0, default 0 | Число выполненных попыток (инкремент при claim). |
NextAttemptAt | DateTimeOffset | Да | index ix_sdo_due (status, next_attempt_at) | Расписание следующей попытки (backoff). |
Status | SnapshotDetachOutboxStatus (enum-string) | Да | хранится как text, varchar(16), default Pending | Pending/Done/Quarantined. |
LastError | string? | Нет | varchar(512) | Последняя ошибка (класс + код, без секретов/PII). |
CreatedAt / UpdatedAt | DateTimeOffset | Да | — | Штампы; CreatedAt — база SLO-лага. |
Конкурентный доступ claim/outcome — оптимистическая блокировка (claim не должен затирать outcome).
Enum SnapshotDetachOutboxStatus (varchar(16))
| Значение | Описание |
|---|
Pending | Ожидает/повторяет detach; берётся job-ом при NextAttemptAt <= now. |
Done | selections подтвердил Detached (или уже был Detached — идемпотентно). Терминальный. |
Quarantined | Poison: Attempts >= MaxAttempts; исключён из выборки, требует runbook + алерт. |
Actor — system (worker), вне tenant (tenant несёт Post); публичного входа нет.
Алгоритм
Внешний вызов selections — вне транзакции (claim и outcome — два коротких согласованных изменения,
детач между ними), чтобы не держать row-lock/соединение на сетевом I/O.
- Контекст. Прогон под распределённой блокировкой; actor = system,
correlationId = jobRunId, вне
tenant. Идемпотентность обеспечивают статус-фильтр + NextAttemptAt + идемпотентный detach (INV-SNP-005).
- Проверка входа. Внешних данных нет; конфиг проверен на старте (
BatchSize > 0, MaxAttempts > 0,
BackoffBase > 0, иначе fail-fast хоста).
- Claim под блокировкой. Если lock занят — прогон пропускается. Иначе цикл батчей до пустоты/
MaxRunDuration: берётся страница записей Status = Pending и NextAttemptAt <= now, по возрастанию
NextAttemptAt (индекс ix_sdo_due), под блокировкой строк с пропуском занятых (FOR UPDATE SKIP LOCKED,
разводит строки параллельного runner-а).
- Claim-фаза (пометка попытки). Для каждой claimed-записи предварительно:
Attempts += 1,
NextAttemptAt = now + backoff(Attempts) (min(BackoffBase · BackoffFactor^(Attempts−1), BackoffCap) +
jitter). Пред-инкремент — безопасное направление: краш до outcome даёт повтор после backoff, а не вечный
цикл. Claim фиксируется отдельным согласованным изменением — снимает row-lock: записи «лизнуты».
- 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 (внутренний алерт).
- События. Интеграционных Kafka-событий нет (детач синхронный). Доменное
SnapshotDetachQuarantinedEvent ({ SnapshotId, PostId, Reason, Attempts }) — только внутренний
алерт/метрика, вне claim-транзакции.
- Согласованность. Claim-фаза (шаг 4) и outcome (шаг 5) — два отдельных коротких согласованных
изменения, между ними — сетевой вызов без открытой транзакции.
- Результат. Наружу — метрики прогона (
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 в selections — Detached.
- 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 calls | selections detach — между claim и outcome, вне открытой транзакции (нет row-lock/соединения на сетевом I/O); тайм-аут DetachCallTimeout |
| Частичная ошибка | continue: сбой на одной записи логируется, не откатывает соседние; запись остаётся Pending (backoff) или Quarantined; следующий tick/прогон повторяет |
Идемпотентность И Retry
| Сценарий | Правило |
|---|
| Job restart | Checkpoint не нужен — прогресс = сам 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 item | Attempts >= MaxAttempts → Quarantined + алерт; исключён из выборки, разбор по runbook |
| Идемпотентность detach | selections для уже-Detached snapshot-а отвечает no-op success (INV-SNP-005) → Status = Done без повторного эффекта |
Наблюдаемость
| Тип | Правило |
|---|
| Logs | job 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 |
| Metrics | news_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) |
| Traces | span на прогон (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
| Показатель | Цель | Обоснование |
|---|
| Лаг detach | p99 ≤ 2 × PollingInterval (≈ 10 мин) при доступном selections | happy-path — синхронно; сбойные — ближайший backoff-цикл |
Отсутствие «вечных» Pending | нет Pending старше суммарного backoff-окна без перехода в Quarantined | метрика oldest_age_seconds + алерт |
| Steady-state backlog | news_snapshot_detach_pending ≈ 0 вне инцидента selections | job — сеть безопасности, не основной путь |
| Идемпотентность | 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, распределённую блокировку (застрявший держатель) |
Тесты
| Тест | Ожидание |
|---|
| Успешный detach | Pending-запись → вызов selections 2xx → Status = Done, метрика succeeded_total++, snapshot Detached |
| Пустой прогон | нет Pending с NextAttemptAt <= now — no-op success, вызовов нет |
Недоступность selections → повтор | 5xx/timeout → запись остаётся Pending, Attempts++, NextAttemptAt сдвинут по backoff; следующий прогон повторяет |
| Идемпотентность | snapshot уже Detached → selections no-op success → Status = Done без повторного эффекта (INV-SNP-005) |
| Poison → карантин | Attempts достигает MaxAttempts → Status = Quarantined + SnapshotDetachQuarantinedEvent/алерт; исключён из выборки |
Dedupe по SnapshotId | повторный enqueue того же snapshot-а не создаёт вторую активную запись (ux_sdo_snapshot) |
| Параллельные инстансы | распределённая блокировка → один runner; при обходе — пропуск занятых строк не даёт двойного claim одной строки |
| Cancellation | отмена между записями — graceful stop, claim-транзакция не рвётся, закоммиченное сохранено |
Обратные ссылки
Автоссылки: где используется этот документ.
Связанные Документы
- Дом переходов/полей: post.md (
Delete → PostDeleted),
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 нет).