|
| 1 | +# pg_table_range: PostgreSQL data-range partition pruning |
| 2 | + |
| 3 | +A PostgreSQL 16+ extension that prunes partitions at planning time from a compact |
| 4 | +per-partition summary of each column's **actual data** — its min/max range for scalar |
| 5 | +columns, or its covering **extent** for range types and PostGIS geometry. This works on |
| 6 | +columns that are *not* the partition key, which native PostgreSQL partition pruning |
| 7 | +cannot eliminate. Pruning is conservative: a partition is removed only when its summary |
| 8 | +provably cannot contain a matching row, so results are always identical to running |
| 9 | +without it. |
| 10 | + |
| 11 | +## Quick Start |
| 12 | + |
| 13 | +Two equivalent interfaces build and maintain the per-partition summaries. Use whichever |
| 14 | +fits your workflow. |
| 15 | + |
| 16 | +### Function interface |
| 17 | + |
| 18 | +```sql |
| 19 | +CREATE EXTENSION pg_table_range; |
| 20 | + |
| 21 | +-- Register one or more columns of a partitioned (or plain) table and build summaries. |
| 22 | +-- Pass the relation as an OID; cast a name with ::regclass::oid. |
| 23 | +SELECT table_range_create('events'::regclass::oid, ARRAY['val', 'created_at']); |
| 24 | + |
| 25 | +-- Queries now prune partitions whose summarized range cannot match the predicate. |
| 26 | +-- Verify with EXPLAIN: non-matching partitions disappear from the plan. |
| 27 | +EXPLAIN (COSTS OFF) SELECT * FROM events WHERE val >= 250; |
| 28 | + |
| 29 | +-- Recompute after bulk loads (also clears staleness); or drop registration entirely. |
| 30 | +SELECT table_range_refresh('events'::regclass::oid); |
| 31 | +SELECT table_range_drop('events'::regclass::oid); |
| 32 | +``` |
| 33 | + |
| 34 | +### Index interface |
| 35 | + |
| 36 | +```sql |
| 37 | +-- Builds the same summaries via a custom index access method. |
| 38 | +CREATE INDEX events_tr ON events USING table_range (val, created_at); |
| 39 | + |
| 40 | +-- Pruning works immediately; REINDEX rebuilds summaries after heavy churn. |
| 41 | +REINDEX INDEX events_tr; |
| 42 | +DROP INDEX events_tr; -- removes the summaries it built |
| 43 | +``` |
| 44 | + |
| 45 | +The index is never used for scans — it exists only to build and own the summaries — so |
| 46 | +it adds no scan-time overhead and is never chosen by the planner for data access. |
| 47 | + |
| 48 | +## How it works |
| 49 | + |
| 50 | +- **Summaries.** For each leaf partition and indexed column, one row in |
| 51 | + `table_range_summary` records the `has_nulls` / `all_nulls` flags plus either the |
| 52 | + column's btree `min`/`max` (scalar columns) or a single covering **extent** — a covering |
| 53 | + range for range types (`range_merge(range_agg(col))`) or the bounding box for PostGIS |
| 54 | + geometry (`ST_Extent(col)`). |
| 55 | +- **Planning.** A `planner_hook` loads all non-stale summaries once per top-level plan |
| 56 | + (a single query, cached for the duration of planning). A `set_rel_pathlist_hook` then |
| 57 | + evaluates each partition's restriction clauses against its cached summary and calls |
| 58 | + `mark_dummy_rel` on any partition that provably cannot match — eliminating it before |
| 59 | + child paths are generated. Wide partition trees therefore do not pay a per-partition |
| 60 | + lookup. |
| 61 | +- **Typed comparisons.** Min/max vs. constant comparisons use each column type's own |
| 62 | + btree compare function, so **any btree-comparable type works**: `bigint` / `int` / |
| 63 | + `smallint`, `numeric`, `real` / `double precision`, `text` / `varchar`, `date`, |
| 64 | + `time`, `timestamp`, `timestamptz`, `uuid`, `boolean`, `oid`, etc. Any conversion |
| 65 | + problem degrades safely to "keep". |
| 66 | +- **Overlap (`&&`).** For range types and PostGIS geometry, an `&&` (overlaps) predicate |
| 67 | + is pruned by testing the constant against the partition's stored extent with |
| 68 | + PostgreSQL's own `&&` operator — so a partition is eliminated when its extent cannot |
| 69 | + overlap the query. |
| 70 | +- **Automatic correctness.** Data changes mark the affected partition's summaries |
| 71 | + *stale*, and stale summaries are never used for pruning — so a change can never cause a |
| 72 | + missing row. The function interface installs row-level triggers |
| 73 | + (`INSERT`/`UPDATE`/`DELETE`/`TRUNCATE`); the index interface marks stale from |
| 74 | + `aminsert`. `table_range_refresh` (or `REINDEX`) recomputes and re-enables pruning. |
| 75 | + A `sql_drop` event trigger removes summaries for any dropped relation or index. |
| 76 | + |
| 77 | +## Performance |
| 78 | + |
| 79 | +The win is at **planning time** on wide trees, where the planner would otherwise build |
| 80 | +paths for every partition. On a 1000-partition table queried by a non-key column |
| 81 | +(`bench/planning_benchmark.sql`, PostgreSQL 18): |
| 82 | + |
| 83 | +| | Planning time | Result | |
| 84 | +|---|---|---| |
| 85 | +| pruning off | ~125 ms | 50 rows | |
| 86 | +| pruning on | ~17 ms | 50 rows | |
| 87 | + |
| 88 | +Summaries are loaded **once per plan** (not per partition); the |
| 89 | +`e2e_per_plan_cache_loads_once_regardless_of_partitions` test asserts exactly one |
| 90 | +catalog load for a 64-partition query. |
| 91 | + |
| 92 | +## Supported predicates |
| 93 | + |
| 94 | +Everything not listed is conservatively **kept** (never mispruned): |
| 95 | + |
| 96 | +- Comparisons `col < c`, `<=`, `=`, `>=`, `>` (either operand order), and `BETWEEN` |
| 97 | + (the planner expands it into two comparisons). |
| 98 | +- `col IS NULL` / `col IS NOT NULL`. |
| 99 | +- `col IN (c1, c2, …)` / `col = ANY(<const array>)` — pruned when no listed value falls |
| 100 | + in the partition's range. |
| 101 | +- `col && const` (overlaps) for range types and PostGIS `geometry` — pruned when the |
| 102 | + partition's extent cannot overlap the constant. |
| 103 | +- Boolean structure composes: `AND` prunes if **any** child proves non-overlap, `OR` |
| 104 | + prunes only if **every** branch does (nested arbitrarily, across any columns). |
| 105 | +- Kept (correct, not yet pruned): `NOT IN` / `<> ALL`, `NOT (...)`, function-wrapped |
| 106 | + columns, and parameters in prepared statements until the plan inlines constants. |
| 107 | + |
| 108 | +## Configuration |
| 109 | + |
| 110 | +- `table_range.enable_pruning` (default `on`) — master switch. |
| 111 | +- `table_range.log_pruning_debug` (default `off`) — log each prune decision. |
| 112 | + |
| 113 | +## Catalog |
| 114 | + |
| 115 | +- `table_range_summary` — one summary row per (owner, leaf partition, column): |
| 116 | + `index_oid`, `relid`, `attnum`, `kind` (`minmax` or `overlap`), `type_name`, |
| 117 | + `min_summary`, `max_summary`, `has_nulls`, `all_nulls`, `stale`, `tuple_version`. |
| 118 | +- `table_range_registered` — parents registered through the function interface and their |
| 119 | + columns. |
| 120 | + |
| 121 | +## Project layout |
| 122 | + |
| 123 | +| File | Responsibility | |
| 124 | +|------|----------------| |
| 125 | +| `src/lib.rs` | GUCs, `_PG_init`, catalog/bootstrap SQL, test wiring | |
| 126 | +| `src/summary_build.rs` | SPI summary build/refresh/drop, registration, staleness triggers | |
| 127 | +| `src/prune_hook.rs` | planner + pathlist hooks, per-plan cache, typed in-memory evaluation | |
| 128 | +| `src/index_am.rs` | `table_range` index access method and operator classes | |
| 129 | +| `src/e2e_tests.rs`, `src/index_am_tests.rs` | end-to-end tests | |
| 130 | + |
| 131 | +## Building and testing |
| 132 | + |
| 133 | +```sh |
| 134 | +cargo pgrx test pg18 # run the end-to-end test suite (PostgreSQL 18) |
| 135 | +cargo pgrx run pg18 # open psql with the extension installed |
| 136 | +``` |
| 137 | + |
| 138 | +Supported targets: PostgreSQL 16, 17, 18. The test suite is entirely end-to-end — |
| 139 | +it builds real partitioned tables, asserts `EXPLAIN` shows the expected partition |
| 140 | +elimination, and verifies results are identical with pruning on and off (the |
| 141 | +no-false-negative guarantee), including insert/delete/drop correctness paths. The |
| 142 | +PostGIS geometry test skips automatically where PostGIS is not installed; CI installs |
| 143 | +PostGIS so it runs there, and overlap pruning is also covered on every target by the |
| 144 | +range-type tests, which exercise the same code path. |
| 145 | + |
| 146 | +## Limitations |
| 147 | + |
| 148 | +- `NOT IN` / `<> ALL`, `NOT (...)`, expression predicates, and parameterized |
| 149 | + prepared-statement plans are kept rather than pruned. |
| 150 | +- Summaries are exact at build/refresh time; between changes and a refresh the affected |
| 151 | + partitions are simply not pruned (always correct, just less selective). |
| 152 | +- The index interface marks a partition stale on insert; recompute with `REINDEX` (or |
| 153 | + `table_range_refresh` for the function interface) to re-enable pruning after churn. |
0 commit comments