ADR-0058: Unified retention BC + time-range partitioning

Status: accepted Date: 2026-05-15 Deciders: Maxim Belkanov

Контекст

Storage roof: laptop 30-40 GB, prod 500 GB. До этого цикла retention fragmented: 3 ticker’a (connector_events / canonical_events / debug_artifacts) + 3 resource без cleanup (ingestion_job_runs, offer_observations, offer_characteristic_raw). Hot tables (observations ~125 MB/день; connector_events ~750 MB/день) растут unbounded; DELETE-induced bloat + autovacuum lag.

Решение

Единый BC core/observability/retention владеющий cleanup для 8 PG resources + MinIO RAW bucket. Hot tables — declarative time-range partitioning + DROP PARTITION; lower-volume — batched DELETE с LIMIT.

  1. RangePartitionManager generic создаёт future partitions (prealloc N периодов) + drops outdated. Шаг нарезки задаётся per-table: месяц (<table>_YYYY_MM, значение по умолчанию) или неделя (<table>_YYYY_MM_DD по понедельнику UTC).
  2. Migration 0136 connector_events + 0137 offer_observations конвертят existing tables в partitioned (rename → _legacy + new partitioned shell + default partition + current month).
  3. Per-resource Cleaner impl в infra/pg/cleaners/. Cycle isolation: ошибка одного cleaner не abort’ит cycle.
  4. RetentionService.RunCycle() orchestration с pg_try_advisory_lock('retention_cleanup') для multi-replica safety.
  5. RetentionTicker с configurable interval (15m local / 1h prod).
  6. Admin endpoint GET /api/v1/admin/retention/status под AdminPathGate.
  7. Config ENV-driven (RETENTION_* namespace) с backward-compat aliases для CONNECTOR_EVENTS_*, CANONICAL_EVENTS_RETENTION_*, S3_RAW_*.
  8. Tier matrix через .env.local-prod.example (aggressive) + deploy/.env.prod.template (relaxed).
  9. cmd/canonical-events-retention-worker deprecated (logic переехала в RetentionService); compose service закомментирован.
  10. observability/connector_events_cleanup.go удалён (logic в new cleaners/connector_events.go).

Дополнение 2026-08-30: шаг нарезки — свойство таблицы, не системы

Исходное решение нарезало ВСЕ горячие таблицы помесячно. На проде это оказалось верно только для таблиц среднего темпа. Дроп партиции — единственный способ вернуть место операционной системе (построчная чистка освобождает страницы лишь под повторное использование внутри той же партиции), а месячную партицию нельзя дропнуть, пока идёт её месяц, плюс запас после него. Для быстрорастущих таблиц это означает, что место возвращается позже, чем кончается.

Понедельно нарезаны, каждая — по своему замеру:

  • connector_events (0368): 17 ГБ/сутки, месячная партиция доходила до 52 ГБ при бюджете клапана 15 GiB.
  • canonical_assignment_events (0369): построчной чистки нет вовсе, вся чистка делегирована дропу; 41 ГБ за тринадцать дней.
  • offer_observations (0397): 95 ГБ на 71,8 млн строк при сроке хранения 14 суток; месячная партиция стала бы дропаемой почти через месяц после того, как каждая её строка просрочена.

Два следствия, важные при добавлении новой таблицы:

  • Запас, с которым партиция дропается после истечения срока, — постоянная неделя. Раньше он равнялся сроку хранения и тем самым удваивал его.
  • Занятость периода менеджер определяет по ДИАПАЗОНУ, а не по имени: живая таблица переводится с месяцев на недели без остановки записи, и неделя, пересекающая границу месяца, обязана быть пропущена, а не создана.

Цена нарезки не нулевая: чтения без верхней границы по ключу партиционирования опрашивают ВСЕ партиции (Merge Append), поэтому каждая лишняя партиция — это лишний проход в горячем запросе. Отсюда запас вперёд держится минимальным.

Последствия

Плюсы

  • Bounded growth для всех high-churn resources.
  • DROP PARTITION instant (~50ms) vs DELETE минуты для high-volume tables.
  • Один config namespace, один ticker, один advisory lock.
  • Admin /retention/status даёт visibility на cleanup state.

Минусы

  • +1 BC, +2 partitioning migrations.
  • _legacy tables живут 30d/3d до natural выселения; следующая migration drops их.
  • Default partition catches edge-case writes; operator alert через tracium_retention_default_partition_rows gauge (когда metrics добавятся в follow-up).

Нейтральные последствия

  • Backward-compat ENV aliases читаются 1 cycle с warning’ами; убираются в следующий cycle.

Рассмотренные альтернативы

A — Distributed retention (per-BC ticker)

Каждый BC сам владеет cleanup. Текущий fragmented pattern. Отвергнут — ENV namespace fragmentation persists; 8 ticker’ов overhead.

B — PG pg_cron extension

Retention через SQL functions + scheduled jobs в БД. Opaque scheduling от Prometheus/observability, новая dependency, не fit для Go-first backend.

C — TimescaleDB hypertables

Native time-series solution. Install extension, change base image, migrate tables. Overkill для текущего volume (<5M rows/30d per table).

Ссылки

  • Spec: docs/superpowers/specs/2026-05-15-storage-retention-refactor-design.md
  • Plan: docs/superpowers/plans/2026-05-15-storage-retention-refactor.md
  • Runbook: docs/docs/40-operations/runbooks/retention.md
  • Migrations: backend/migrations/0136_*.sql, 0137_*.sql; недельная нарезка — 0368_*.sql, 0369_*.sql, 0397_*.sql
  • BC: backend/internal/core/observability/retention/