ADR-0193: Partitioned history with no default partition and one maintainer
- Status: accepted; amends ADR-0134
- Date: 2026-10-13
- Deciders: Mahdi Amirabdollahi
Context
metric_sample and message_index were partitioned by day, each with a default partition and its own maintainer. The default partition made the maintainer move rows out of it on every run, under a lock that stalled writers, and Postgres refuses DETACH PARTITION ... CONCURRENTLY on a table that has one, so retention detached with a plain DETACH that blocks every insert behind any long query. The two maintainers were two copies of the same code. broker_event, rr_event and audit_event grow with traffic as well, and were purged row by row: bloat, vacuum load and a long tail of batches.
Decision
- Five tables are range-partitioned on a time column, each with the partition column in its primary key:
metric_sample.ts,message_index.observed_at,broker_event.received_atandrr_event.tsby day, andaudit_event.tsby month. Bounds are UTC. No table has a default partition, and the schema lint refuses one.rr_eventhas no foreign key to its flow. - One
PartitionMaintainerinkernel.lifecycle.partitionreplaces both maintainers. Each module contributes aPartitionSpecbean: table, column, grain, how many periods ahead and the heap storage parameters, which a partitioned parent cannot carry, so every partition gets them. - It runs at start-up, in a lifecycle phase before scraping, capture and jobs, and every hour as the installation-wide
partitionsjob. It creates the table, then attaches it, becauseATTACHtakesSHARE UPDATE EXCLUSIVEon the parent wherePARTITION OFtakesACCESS EXCLUSIVE. A transaction-scoped advisory lock makes replicas take turns. A partition whose period closed is frozen and analyzed once (marked by a table comment); each parent is analyzed daily, because autovacuum never analyzes a partitioned parent. - Retention drops partitions. A store's
purgeBatchcallsPartitionDrops.dropOldest, which finishes any detach a crash left pending, then runsDETACH ... CONCURRENTLYandDROP. The housekeeper and the store SPI are unchanged. - Writes never use broker time as the partition column. The columns are the database's clock or the writer's. A write that still finds no partition fails with SQLState
23514; the writer callsensureAhead()and retries once, in a new transaction. Thepartitionshealth contributor is DOWN while a table lacks the current period's partition. - Audit (ADR-0190). A month is dropped only when no row in it is outside the eligible set: acknowledged by every reliable receiver or older than the maximum age, and covered by no hold. The gaps for rows a receiver had not acknowledged are recorded first. Otherwise the month is purged row by row, as before.
- Cascaded children are purged first in bounded batches (
transfer_copied,bulk_run_item), and the parents after them, so no statement cascades through an unbounded number of rows. - A subscription's own shorter retention on the message index is still a bulk delete, now in the hourly
message-index-retentionjob, since a shared partition cannot be dropped for one subscription.
Consequences
- Retention is a catalog operation: no bloat, no vacuum debt and no blocked writer.
- Writes outside the maintained window fail until the maintainer runs. That is deliberate and visible in health, and the retry covers a missed run; the cost is that a partition column must never come from a broker's clock.
- Primary keys are no longer unique on the key alone: the identity or UUID prevents duplicates, and the database cannot enforce more across partitions.
- Rows of
rr_eventoutlive their flow by up to a day. - The
metric.partition-maintainer-cronsetting is gone; the hourly job is not configurable. - Operators recreate the database (ADR-0192).
Alternatives considered
- Keep a default partition. Rejected: it blocks concurrent detach and invites rows that no date range can drop.
PARTITION OFfor creation. Rejected: it takesACCESS EXCLUSIVEon the parent.pg_partman. Rejected: an extension that a managed external database may not allow.- Keep deleting
broker_eventandrr_eventrow by row. Rejected: it is the load partitioning removes.