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