Skip to content

Commit 0916cb0

Browse files
bitnerclaude
andcommitted
Initial commit: pg_table_range
A PostgreSQL 16+ extension that prunes partitions at planning time from a compact per-partition summary of each column's actual data — btree min/max for scalar columns and a covering extent for range types and PostGIS geometry — including on columns that are not the partition key. Pruning is conservative: results are always identical to running without it. Two interfaces build and maintain summaries: SQL functions (table_range_create/refresh/drop) and a custom index access method (CREATE INDEX ... USING table_range). A planner hook loads summaries once per plan and eliminates non-matching partitions before child-path planning. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
0 parents  commit 0916cb0

15 files changed

Lines changed: 2867 additions & 0 deletions

.cargo/config.toml

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
# pgrx cdylib and test builds resolve PostgreSQL symbols at dlopen time, so the
2+
# linker must not reject the (intentionally) undefined PostgreSQL symbols at link
3+
# time.
4+
5+
[target.'cfg(target_os = "macos")']
6+
rustflags = ["-Clink-arg=-Wl,-undefined,dynamic_lookup"]
7+
8+
# Use the default linker and downgrade unresolved-symbol errors to warnings. Naming
9+
# the exact target triple also overrides any globally configured alternative linker
10+
# (e.g. mold) that cannot defer PostgreSQL symbol resolution to load time.
11+
[target.x86_64-unknown-linux-gnu]
12+
rustflags = ["-Clinker=cc", "-Clink-arg=-Wl,--warn-unresolved-symbols"]

.github/workflows/ci.yml

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
name: CI
2+
3+
on:
4+
push:
5+
branches: [main]
6+
pull_request:
7+
8+
env:
9+
CARGO_TERM_COLOR: always
10+
PGRX_VERSION: "0.18.1"
11+
12+
jobs:
13+
test:
14+
name: test ${{ matrix.pg }}
15+
runs-on: ubuntu-latest
16+
strategy:
17+
fail-fast: false
18+
matrix:
19+
pg: [pg16, pg17, pg18]
20+
steps:
21+
- uses: actions/checkout@v4
22+
23+
- name: Install build dependencies
24+
run: |
25+
sudo apt-get update
26+
sudo apt-get install -y --no-install-recommends \
27+
build-essential bison flex libreadline-dev zlib1g-dev \
28+
libicu-dev pkg-config libclang-dev clang
29+
30+
- name: Install Rust toolchain (from rust-toolchain.toml)
31+
run: rustup show
32+
33+
- name: Cache cargo registry and build
34+
uses: actions/cache@v4
35+
with:
36+
path: |
37+
~/.cargo/registry
38+
~/.cargo/git
39+
target
40+
key: cargo-${{ runner.os }}-${{ matrix.pg }}-${{ hashFiles('Cargo.toml') }}
41+
42+
- name: Cache pgrx-managed PostgreSQL
43+
uses: actions/cache@v4
44+
with:
45+
path: ~/.pgrx
46+
key: pgrx-${{ runner.os }}-${{ matrix.pg }}-${{ env.PGRX_VERSION }}
47+
48+
- name: Install cargo-pgrx
49+
run: cargo install cargo-pgrx --version "=${{ env.PGRX_VERSION }}" --locked
50+
51+
- name: Initialize pgrx (build ${{ matrix.pg }} from source)
52+
run: cargo pgrx init --${{ matrix.pg }} download
53+
54+
- name: Clippy (pg18 only; needs initialized pgrx)
55+
if: matrix.pg == 'pg18'
56+
run: |
57+
rustup component add clippy
58+
cargo clippy --no-default-features --features pg18,pg_test -- -D warnings
59+
60+
- name: Run tests
61+
run: cargo pgrx test ${{ matrix.pg }}
62+
63+
postgis:
64+
name: postgis (pg16)
65+
runs-on: ubuntu-latest
66+
steps:
67+
- uses: actions/checkout@v4
68+
69+
- name: Install PostgreSQL 16 + PostGIS + build deps
70+
run: |
71+
sudo apt-get update
72+
sudo apt-get install -y --no-install-recommends \
73+
postgresql-16 postgresql-server-dev-16 postgresql-16-postgis-3 \
74+
build-essential libreadline-dev zlib1g-dev libicu-dev \
75+
pkg-config libclang-dev clang
76+
sudo systemctl stop postgresql || true
77+
78+
- name: Make the extension install dirs writable for pgrx
79+
run: |
80+
PGBIN=/usr/lib/postgresql/16/bin
81+
sudo chmod -R a+w "$($PGBIN/pg_config --pkglibdir)" \
82+
"$($PGBIN/pg_config --sharedir)/extension"
83+
84+
- name: Install Rust toolchain (from rust-toolchain.toml)
85+
run: rustup show
86+
87+
- name: Cache cargo registry and build
88+
uses: actions/cache@v4
89+
with:
90+
path: |
91+
~/.cargo/registry
92+
~/.cargo/git
93+
target
94+
key: cargo-postgis-${{ hashFiles('Cargo.toml') }}
95+
96+
- name: Install cargo-pgrx
97+
run: cargo install cargo-pgrx --version "=${{ env.PGRX_VERSION }}" --locked
98+
99+
- name: Point pgrx at the system PostgreSQL 16
100+
run: cargo pgrx init --pg16 /usr/lib/postgresql/16/bin/pg_config
101+
102+
- name: Run tests (PostGIS available -> geometry test runs for real)
103+
run: cargo pgrx test pg16
104+
105+
fmt:
106+
name: rustfmt
107+
runs-on: ubuntu-latest
108+
steps:
109+
- uses: actions/checkout@v4
110+
- run: rustup component add rustfmt
111+
- run: cargo fmt --all --check

.gitignore

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
# Rust / Cargo
2+
/target
3+
**/*.rs.bk
4+
Cargo.lock
5+
6+
# Editors / OS
7+
.DS_Store
8+
.idea/
9+
*.iml
10+
11+
# Project working notes (not part of the published extension)
12+
/PLAN.md

Cargo.toml

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
[package]
2+
name = "pg_table_range"
3+
version = "0.1.0"
4+
edition = "2021"
5+
license = "MIT"
6+
description = "PostgreSQL extension for planning-time partition pruning by per-partition data ranges, including non-key columns."
7+
readme = "README.md"
8+
repository = "https://github.com/bitner/pg_table_range"
9+
keywords = ["postgresql", "pgrx", "partitioning", "pruning", "extension"]
10+
categories = ["database"]
11+
12+
[lib]
13+
crate-type = ["cdylib", "lib"]
14+
15+
[features]
16+
default = ["pg18"]
17+
pg16 = ["pgrx/pg16", "pgrx-tests/pg16" ]
18+
pg17 = ["pgrx/pg17", "pgrx-tests/pg17" ]
19+
pg18 = ["pgrx/pg18", "pgrx-tests/pg18" ]
20+
pg_test = []
21+
22+
[dependencies]
23+
pgrx = "=0.18.1"
24+
25+
[dev-dependencies]
26+
pgrx-tests = "=0.18.1"
27+
28+
[profile.dev]
29+
panic = "unwind"
30+
31+
[profile.release]
32+
panic = "unwind"
33+
opt-level = 3
34+
lto = "fat"
35+
codegen-units = 1

LICENSE

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
MIT License
2+
3+
Copyright (c) 2026 David Bitner
4+
5+
Permission is hereby granted, free of charge, to any person obtaining a copy
6+
of this software and associated documentation files (the "Software"), to deal
7+
in the Software without restriction, including without limitation the rights
8+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9+
copies of the Software, and to permit persons to whom the Software is
10+
furnished to do so, subject to the following conditions:
11+
12+
The above copyright notice and this permission notice shall be included in all
13+
copies or substantial portions of the Software.
14+
15+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21+
SOFTWARE.

README.md

Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
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.

bench/planning_benchmark.sql

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
-- Planning-time benchmark for table_range pruning.
2+
--
3+
-- Builds a wide partition tree where the queried column is NOT the partition key, so
4+
-- native PostgreSQL pruning cannot help, and compares planning time + plan size with
5+
-- table_range pruning off vs. on. Run with:
6+
--
7+
-- cargo pgrx run pg18
8+
-- \i bench/planning_benchmark.sql
9+
--
10+
-- Look at the "Planning Time" line and the number of child plans in each EXPLAIN.
11+
12+
\set part_count 1000
13+
14+
DROP TABLE IF EXISTS bench_events CASCADE;
15+
CREATE TABLE bench_events (region int, val bigint) PARTITION BY LIST (region);
16+
17+
-- One partition per region; each holds a disjoint 1000-wide band of `val`.
18+
SELECT format(
19+
'CREATE TABLE bench_events_%s PARTITION OF bench_events FOR VALUES IN (%s);',
20+
g, g
21+
)
22+
FROM generate_series(1, :part_count) g \gexec
23+
24+
INSERT INTO bench_events
25+
SELECT g, (g * 1000) + s
26+
FROM generate_series(1, :part_count) g,
27+
generate_series(0, 49) s;
28+
29+
ANALYZE bench_events;
30+
31+
SELECT table_range_create('bench_events'::regclass::oid, ARRAY['val']);
32+
33+
\echo '==================== pruning OFF ===================='
34+
SET table_range.enable_pruning = off;
35+
EXPLAIN (ANALYZE, COSTS OFF, TIMING OFF, SUMMARY ON)
36+
SELECT * FROM bench_events WHERE val BETWEEN 500000 AND 500049;
37+
38+
\echo '==================== pruning ON ===================='
39+
SET table_range.enable_pruning = on;
40+
EXPLAIN (ANALYZE, COSTS OFF, TIMING OFF, SUMMARY ON)
41+
SELECT * FROM bench_events WHERE val BETWEEN 500000 AND 500049;
42+
43+
\echo '==================== correctness check (must match) ===================='
44+
SET table_range.enable_pruning = off;
45+
SELECT count(*) AS off_count FROM bench_events WHERE val BETWEEN 500000 AND 500049;
46+
SET table_range.enable_pruning = on;
47+
SELECT count(*) AS on_count FROM bench_events WHERE val BETWEEN 500000 AND 500049;
48+
49+
DROP TABLE bench_events CASCADE;

0 commit comments

Comments
 (0)