You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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>
Copy file name to clipboardExpand all lines: CLAUDE.md
+1Lines changed: 1 addition & 0 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -218,6 +218,7 @@ Key settings in `internal/config/config.go`:
218
218
-`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}`.
219
219
-`GRPC_MAX_RECV_MB` (16), `GRPC_MAX_CONCURRENT_STREAMS` (1000) — OTLP gRPC server caps, validated to 1..256 and 1..1_000_000
220
220
-`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`.
221
222
-`APP_ENV` (`"development"`), `OTELCONTEXT_ALLOW_SQLITE_PROD` (false) — SQLite is refused when `APP_ENV=production` unless the allow flag is set
Copy file name to clipboardExpand all lines: docs/OPERATIONS.md
+21Lines changed: 21 additions & 0 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -115,6 +115,27 @@ SQLite is rejected at startup when `APP_ENV=production` unless you explicitly op
115
115
116
116
**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 = ?", ...)`).
117
117
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)
0 commit comments