Skip to content

Commit d633e2a

Browse files
aksOpsclaude
andcommitted
feat(storage): Postgres daily partitioning + DROP-PARTITION retention
Adds an opt-in Postgres declarative-partitioning adapter for the `logs` table, gated by `DB_POSTGRES_PARTITIONING=daily` (default off). When enabled, `logs` is provisioned as a `RANGE PARTITION BY (timestamp)` parent with the composite PK `(id, timestamp)`; AutoMigrate skips the model and a `PartitionScheduler` runs hourly to (a) ensure today + lookahead future daily partitions exist and (b) DROP partitions whose range predates the retention cutoff. DROP PARTITION beats row-level DELETE for retention by orders of magnitude. `RetentionScheduler` reads `repo.LogsPartitioned()` and skips the `logs` DELETE branch when partitioning is active so we never run two retention paths against the same table. Greenfield only — startup refuses to enable partitioning if `logs` already exists as a non-partitioned table. Migrating an unpartitioned `logs` to partitioned requires data movement and is out of scope. `pg_trgm` GIN indexes are declared on the parent and propagate automatically to current/future child partitions (Postgres ≥ 11). New telemetry: `otelcontext_partitions_dropped_total` (counter) and `otelcontext_partitions_active` (gauge). New env vars validated at config load: `DB_POSTGRES_PARTITIONING` ("", none, daily) and `DB_PARTITION_LOOKAHEAD_DAYS` (0..365). Daily mode is rejected when DB_DRIVER != postgres. Tests: 6 Postgres integration tests (testcontainers, auto-skipped without docker) covering the partitioned schema, child-partition routing, DROP_EXPIRED, greenfield guard, scheduler lifecycle, and pg_trgm GIN inheritance. 4 unit tests for the date/identifier helpers. Docs updated in CLAUDE.md and OPERATIONS.md. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 96ec26e commit d633e2a

12 files changed

Lines changed: 1055 additions & 8 deletions

File tree

CLAUDE.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -218,6 +218,7 @@ Key settings in `internal/config/config.go`:
218218
- `INGEST_ASYNC_ENABLED` (true), `INGEST_PIPELINE_QUEUE_SIZE` (50000), `INGEST_PIPELINE_WORKERS` (8) — async ingest pipeline (`internal/ingest/pipeline.go`). Hybrid backpressure: <90% accept all, 90–100% drop healthy batches (errors/slow always pass), 100% return gRPC `RESOURCE_EXHAUSTED`. Set `INGEST_ASYNC_ENABLED=false` to revert to synchronous DB writes inside `Export()`. Drops surface as `otelcontext_ingest_pipeline_dropped_total{signal,reason}`.
219219
- `GRPC_MAX_RECV_MB` (16), `GRPC_MAX_CONCURRENT_STREAMS` (1000) — OTLP gRPC server caps, validated to 1..256 and 1..1_000_000
220220
- `RETENTION_BATCH_SIZE` (50000), `RETENTION_BATCH_SLEEP_MS` (1) — purge pacing; raise the sleep on busy production DBs
221+
- `DB_POSTGRES_PARTITIONING` (`""`), `DB_PARTITION_LOOKAHEAD_DAYS` (3) — opt-in Postgres declarative range partitioning of the `logs` table by day. When `daily`, `logs` is provisioned as a partitioned parent (greenfield only — refuses to start if `logs` already exists unpartitioned), the `PartitionScheduler` maintains lookahead partitions and drops expired ones via `DROP TABLE`, and `RetentionScheduler` skips the row-level DELETE for `logs`. Watch `otelcontext_partitions_dropped_total` and `otelcontext_partitions_active`.
221222
- `APP_ENV` (`"development"`), `OTELCONTEXT_ALLOW_SQLITE_PROD` (false) — SQLite is refused when `APP_ENV=production` unless the allow flag is set
222223

223224
### Authentication

docs/OPERATIONS.md

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,27 @@ SQLite is rejected at startup when `APP_ENV=production` unless you explicitly op
115115

116116
**Multi-tenancy.** Every row carries a `tenant_id` column. The write path reads `X-Tenant-ID` (HTTP) or `x-tenant-id` (gRPC metadata) and populates the column. The read path attaches the tenant from the request context to every repository query (`Where("tenant_id = ?", ...)`).
117117

118+
### Postgres declarative partitioning (opt-in)
119+
120+
| Setting | Default | When to enable |
121+
|---|---|---|
122+
| `DB_POSTGRES_PARTITIONING` | `""` (off) | High-volume Postgres deployments where row-level retention DELETE on `logs` becomes the bottleneck |
123+
| `DB_PARTITION_LOOKAHEAD_DAYS` | `3` | Future daily partitions to keep staged. Raise if your DB is far from the app or your retention is short |
124+
125+
When `DB_POSTGRES_PARTITIONING=daily`:
126+
127+
- `logs` is provisioned as a `RANGE PARTITION BY (timestamp)` parent with the composite PK `(id, timestamp)`. AutoMigrate sees an existing table and skips the model.
128+
- Initial partitions cover yesterday + today + `lookahead` future days. Yesterday absorbs late-arriving events at the day-boundary rollover.
129+
- The PartitionScheduler runs hourly, idempotently ensures upcoming partitions exist, and drops any partition whose entire range predates the retention cutoff. Drop is `DROP TABLE IF EXISTS <child>` — orders of magnitude faster than the row-by-row DELETE.
130+
- RetentionScheduler skips its `logs` DELETE branch when partitioning is active. `traces` and `metric_buckets` continue to use the existing batched DELETE path.
131+
- `pg_trgm` GIN indexes on `logs.body` and `logs.service_name` are declared on the parent and propagate automatically to current and future partitions.
132+
133+
**Greenfield only.** Startup refuses to enable partitioning if `logs` already exists as a non-partitioned table — migrating an unpartitioned `logs` to a partitioned one requires copying rows into a swapped table and is out of scope for the current phase. Drop the table or migrate manually before flipping the flag.
134+
135+
Telemetry:
136+
- `otelcontext_partitions_dropped_total` — increments by `n` when the scheduler drops `n` partitions on a tick
137+
- `otelcontext_partitions_active` — current partition count attached to the parent. Steady-state ≈ `HOT_RETENTION_DAYS + DB_PARTITION_LOOKAHEAD_DAYS + 1`. Alert when this gauge climbs unbounded (drop loop stuck) or falls toward zero (over-aggressive drop)
138+
118139
### Log search index
119140

120141
| Driver | Index | Ranking |

internal/config/config.go

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,19 @@ type Config struct {
3030
DBMaxIdleConns int
3131
DBConnMaxLifetime string // e.g. "1h", "30m"
3232

33+
// Postgres-only opt-in: declarative range partitioning of the logs table by
34+
// day. When set to "daily", AutoMigrate provisions logs as a partitioned
35+
// table and the PartitionScheduler creates lookahead partitions and drops
36+
// expired ones (DROP PARTITION beats DELETE for retention by orders of
37+
// magnitude). Greenfield only — startup refuses if `logs` already exists
38+
// as a non-partitioned table. Empty / "none" = legacy unpartitioned schema.
39+
DBPostgresPartitioning string
40+
41+
// Number of future daily partitions to maintain ahead of "today" when
42+
// DBPostgresPartitioning=daily. Defaults to 3. Tune up if your retention
43+
// policy is short and ingest spikes around a daily boundary.
44+
DBPartitionLookaheadDays int
45+
3346
// Retention
3447
HotRetentionDays int
3548

@@ -186,6 +199,10 @@ func Load(customPath string) (*Config, error) {
186199
DBMaxIdleConns: getEnvInt("DB_MAX_IDLE_CONNS", 10),
187200
DBConnMaxLifetime: getEnv("DB_CONN_MAX_LIFETIME", "1h"),
188201

202+
// Postgres partitioning (opt-in). Default empty = legacy unpartitioned.
203+
DBPostgresPartitioning: strings.ToLower(strings.TrimSpace(getEnv("DB_POSTGRES_PARTITIONING", ""))),
204+
DBPartitionLookaheadDays: getEnvInt("DB_PARTITION_LOOKAHEAD_DAYS", 3),
205+
189206
// Retention
190207
HotRetentionDays: getEnvInt("HOT_RETENTION_DAYS", 7),
191208
RetentionBatchSize: getEnvInt("RETENTION_BATCH_SIZE", 50000),
@@ -324,6 +341,27 @@ func (c *Config) Validate() error {
324341
return fmt.Errorf("invalid DB_DRIVER %q: must be one of sqlite, postgres, mysql, mssql", c.DBDriver)
325342
}
326343

344+
// Partitioning is Postgres-only. Reject mismatched configs at startup so
345+
// the operator finds out immediately rather than silently running in
346+
// unpartitioned mode.
347+
switch c.DBPostgresPartitioning {
348+
case "", "none", "daily":
349+
// ok
350+
default:
351+
return fmt.Errorf("invalid DB_POSTGRES_PARTITIONING %q: must be one of \"\", \"none\", \"daily\"", c.DBPostgresPartitioning)
352+
}
353+
if c.DBPostgresPartitioning == "daily" {
354+
drv := strings.ToLower(c.DBDriver)
355+
if drv != "postgres" && drv != "postgresql" {
356+
return fmt.Errorf("DB_POSTGRES_PARTITIONING=daily requires DB_DRIVER=postgres, got %q", c.DBDriver)
357+
}
358+
}
359+
// 0 == "use default at the storage layer" so direct struct construction
360+
// (tests, embedded callers) doesn't have to set it.
361+
if c.DBPartitionLookaheadDays < 0 || c.DBPartitionLookaheadDays > 365 {
362+
return fmt.Errorf("DB_PARTITION_LOOKAHEAD_DAYS must be between 0 and 365, got %d", c.DBPartitionLookaheadDays)
363+
}
364+
327365
// Numeric ranges.
328366
// Upper bound on HOT_RETENTION_DAYS guards against int64 nanosecond overflow in
329367
// time.Duration(days) * 24 * time.Hour (overflow above ~106751 days flips the

internal/storage/factory.go

Lines changed: 46 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -184,7 +184,33 @@ func getEnvPoolDuration(key string, fallback time.Duration) time.Duration {
184184
}
185185

186186
// AutoMigrateModels runs GORM auto-migration for all OtelContext models.
187+
//
188+
// When DB_POSTGRES_PARTITIONING=daily, the `logs` table is provisioned as a
189+
// declarative range-partitioned parent BEFORE GORM AutoMigrate runs, so
190+
// AutoMigrate sees an existing table and skips it (GORM's IF NOT EXISTS
191+
// behaviour). This is greenfield-only — see setupPostgresPartitionedLogs for
192+
// the safety check.
187193
func AutoMigrateModels(db *gorm.DB, driver string) error {
194+
return AutoMigrateModelsWithOptions(db, driver, MigrateOptions{})
195+
}
196+
197+
// MigrateOptions tunes AutoMigrateModelsWithOptions. Empty zero value
198+
// preserves the legacy AutoMigrateModels behaviour.
199+
type MigrateOptions struct {
200+
// PostgresPartitioning, when "daily", provisions `logs` as a partitioned
201+
// table. Any other value (including the empty string) keeps the legacy
202+
// unpartitioned schema.
203+
PostgresPartitioning string
204+
// PartitionLookaheadDays is the number of future daily partitions to
205+
// pre-create at boot. Defaults to 3 when zero.
206+
PartitionLookaheadDays int
207+
}
208+
209+
// AutoMigrateModelsWithOptions is the option-driven variant of
210+
// AutoMigrateModels. Existing callers should continue to use AutoMigrateModels
211+
// — the options entry point is for new wiring (currently main.go) that needs
212+
// to plumb the partitioning flag.
213+
func AutoMigrateModelsWithOptions(db *gorm.DB, driver string, opts MigrateOptions) error {
188214
driver = strings.ToLower(driver)
189215

190216
// Disable FK checks during migration for MySQL.
@@ -197,7 +223,26 @@ func AutoMigrateModels(db *gorm.DB, driver string) error {
197223
log.Println("🔓 Disabled foreign key checks for migration")
198224
}
199225

200-
if err := db.AutoMigrate(&Trace{}, &Span{}, &Log{}, &MetricBucket{}); err != nil {
226+
// Postgres partitioning: provision the partitioned `logs` parent + initial
227+
// daily partitions BEFORE GORM AutoMigrate runs, and skip Log from
228+
// AutoMigrate's slice. AutoMigrate would otherwise try to ALTER the
229+
// timestamp column (because the model tag doesn't carry an explicit
230+
// `not null` and the partitioned PK forces NOT NULL on the column),
231+
// which Postgres rejects because the column is part of the partition key.
232+
logsPartitioned := false
233+
if (driver == "postgres" || driver == "postgresql") && opts.PostgresPartitioning == PartitioningModeDaily {
234+
if err := setupPostgresPartitionedLogs(db, opts.PartitionLookaheadDays); err != nil {
235+
return fmt.Errorf("setup partitioned logs: %w", err)
236+
}
237+
log.Printf("📦 Postgres: declarative partitioning enabled (daily, lookahead=%d days)", opts.PartitionLookaheadDays)
238+
logsPartitioned = true
239+
}
240+
241+
migrateModels := []any{&Trace{}, &Span{}, &MetricBucket{}}
242+
if !logsPartitioned {
243+
migrateModels = append(migrateModels, &Log{})
244+
}
245+
if err := db.AutoMigrate(migrateModels...); err != nil {
201246
return fmt.Errorf("failed to migrate database: %w", err)
202247
}
203248

0 commit comments

Comments
 (0)