Skip to content

Commit 5cd6672

Browse files
authored
Merge pull request #1 from bitner/consolidate-on-index-am
Consolidate on the index access method; auto-provision opclasses
2 parents ba93502 + 09fb46e commit 5cd6672

7 files changed

Lines changed: 203 additions & 480 deletions

File tree

README.md

Lines changed: 45 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -12,41 +12,41 @@ without it.
1212

1313
## Quick Start
1414

15-
Two equivalent interfaces build and maintain the per-partition summaries. Use whichever
16-
fits your workflow.
17-
18-
### Function interface
15+
Summaries are built and maintained through a custom index access method, so pruning
16+
follows the normal index lifecycle (`pg_dump`/restore, `REINDEX`, `DROP INDEX`).
1917

2018
```sql
2119
CREATE EXTENSION pg_table_range;
2220

23-
-- Register one or more columns of a partitioned (or plain) table and build summaries.
24-
-- Pass the relation as an OID; cast a name with ::regclass::oid.
25-
SELECT table_range_create('events'::regclass::oid, ARRAY['val', 'created_at']);
21+
-- Summarize one or more columns of a partitioned (or plain) table.
22+
CREATE INDEX events_tr ON events USING table_range (val, created_at);
2623

27-
-- Queries now prune partitions whose summarized range cannot match the predicate.
24+
-- Queries now prune partitions whose summary cannot match the predicate.
2825
-- Verify with EXPLAIN: non-matching partitions disappear from the plan.
2926
EXPLAIN (COSTS OFF) SELECT * FROM events WHERE val >= 250;
3027

31-
-- Recompute after bulk loads (also clears staleness); or drop registration entirely.
32-
SELECT table_range_refresh('events'::regclass::oid);
33-
SELECT table_range_drop('events'::regclass::oid);
34-
```
35-
36-
### Index interface
37-
38-
```sql
39-
-- Builds the same summaries via a custom index access method.
40-
CREATE INDEX events_tr ON events USING table_range (val, created_at);
41-
42-
-- Pruning works immediately; REINDEX rebuilds summaries after heavy churn.
28+
-- Recompute after heavy churn; or drop the summaries entirely.
4329
REINDEX INDEX events_tr;
4430
DROP INDEX events_tr; -- removes the summaries it built
4531
```
4632

4733
The index is never used for scans — it exists only to build and own the summaries — so
4834
it adds no scan-time overhead and is never chosen by the planner for data access.
4935

36+
### Supported column types (no setup, including PostGIS)
37+
38+
`CREATE INDEX … USING table_range` works on any **btree-comparable** type and any
39+
**range** type out of the box. The required operator classes are provisioned
40+
automatically by mirroring the types that already have a btree/range operator class — and
41+
that mirror re-runs whenever an extension is installed, so **PostGIS geometry works the
42+
moment you `CREATE EXTENSION postgis`, with no extra step**:
43+
44+
```sql
45+
CREATE EXTENSION postgis; -- geometry opclass auto-registers
46+
CREATE INDEX places_tr ON places USING table_range (geom);
47+
EXPLAIN (COSTS OFF) SELECT * FROM places WHERE geom && ST_MakeEnvelope(0,0,10,10);
48+
```
49+
5050
## How it works
5151

5252
- **Summaries.** For each leaf partition and indexed column, one row in
@@ -69,23 +69,30 @@ it adds no scan-time overhead and is never chosen by the planner for data access
6969
is pruned by testing the constant against the partition's stored extent with
7070
PostgreSQL's own `&&` operator — so a partition is eliminated when its extent cannot
7171
overlap the query.
72-
- **Automatic correctness.** Data changes mark the affected partition's summaries
73-
*stale*, and stale summaries are never used for pruning — so a change can never cause a
74-
missing row. The function interface installs row-level triggers
75-
(`INSERT`/`UPDATE`/`DELETE`/`TRUNCATE`); the index interface marks stale from
76-
`aminsert`. `table_range_refresh` (or `REINDEX`) recomputes and re-enables pruning.
77-
A `sql_drop` event trigger removes summaries for any dropped relation or index.
72+
- **Automatic correctness.** An insert that extends a partition marks its summary
73+
*stale* (via the index's `aminsert`), and stale summaries are never used for pruning —
74+
so a change can never cause a missing row. Deletes only shrink a partition's true
75+
range, so the summary stays conservatively wide and remains safe. `REINDEX` recomputes
76+
and re-enables pruning after churn, and a `sql_drop` event trigger removes a dropped
77+
index's (or table's) summaries.
7878

7979
## Performance
8080

81-
The win is at **planning time** on wide trees, where the planner would otherwise build
82-
paths for every partition. On a 1000-partition table queried by a non-key column
83-
(`bench/planning_benchmark.sql`, PostgreSQL 18):
81+
The benefit is at **execution**: a selective predicate on a non-key column scans only the
82+
matching partition instead of every partition. On 100 partitions × 30k rows = 3M rows
83+
(`bench/planning_benchmark.sql`, PostgreSQL 18, warm):
84+
85+
| | Total query time (plan + exec) |
86+
|---|---|
87+
| pruning off (scans all 100 partitions) | ~125 ms |
88+
| pruning on (scans 1 partition) | ~18 ms |
8489

85-
| | Planning time | Result |
86-
|---|---|---|
87-
| pruning off | ~125 ms | 50 rows |
88-
| pruning on | ~17 ms | 50 rows |
90+
Pruning is **not** a free planning-time win: it adds a small per-plan overhead (loading
91+
summaries once, then evaluating each partition — single-digit to low-tens of ms on
92+
hundreds of partitions). It pays off when the partitions it eliminates are large enough
93+
that avoiding their scan outweighs that overhead — so it helps most on **large
94+
partitions with a selective non-key predicate**, and can be a slight net cost on tiny
95+
partitions. Use `table_range.enable_pruning` to measure both ways on your workload.
8996

9097
Summaries are loaded **once per plan** (not per partition); the
9198
`e2e_per_plan_cache_loads_once_regardless_of_partitions` test asserts exactly one
@@ -114,20 +121,18 @@ Everything not listed is conservatively **kept** (never mispruned):
114121

115122
## Catalog
116123

117-
- `table_range_summary` — one summary row per (owner, leaf partition, column):
124+
- `table_range_summary` — one summary row per (index, leaf partition, column):
118125
`index_oid`, `relid`, `attnum`, `kind` (`minmax` or `overlap`), `type_name`,
119126
`min_summary`, `max_summary`, `has_nulls`, `all_nulls`, `stale`, `tuple_version`.
120-
- `table_range_registered` — parents registered through the function interface and their
121-
columns.
122127

123128
## Project layout
124129

125130
| File | Responsibility |
126131
|------|----------------|
127132
| `src/lib.rs` | GUCs, `_PG_init`, catalog/bootstrap SQL, test wiring |
128-
| `src/summary_build.rs` | SPI summary build/refresh/drop, registration, staleness triggers |
133+
| `src/summary_build.rs` | SPI summary build (scalar min/max + range/geometry extent) |
129134
| `src/prune_hook.rs` | planner + pathlist hooks, per-plan cache, typed in-memory evaluation |
130-
| `src/index_am.rs` | `table_range` index access method and operator classes |
135+
| `src/index_am.rs` | `table_range` index access method + automatic operator-class provisioning |
131136
| `src/e2e_tests.rs`, `src/index_am_tests.rs` | end-to-end tests |
132137

133138
## Building and testing
@@ -149,7 +154,5 @@ range-type tests, which exercise the same code path.
149154

150155
- `NOT IN` / `<> ALL`, `NOT (...)`, expression predicates, and parameterized
151156
prepared-statement plans are kept rather than pruned.
152-
- Summaries are exact at build/refresh time; between changes and a refresh the affected
153-
partitions are simply not pruned (always correct, just less selective).
154-
- The index interface marks a partition stale on insert; recompute with `REINDEX` (or
155-
`table_range_refresh` for the function interface) to re-enable pruning after churn.
157+
- Summaries are exact at build time; an insert that extends a partition marks it stale
158+
(not pruned, but still correct) until the next `REINDEX`.

bench/planning_benchmark.sql

Lines changed: 30 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,49 +1,51 @@
1-
-- Planning-time benchmark for table_range pruning.
1+
-- Benchmark for table_range pruning.
22
--
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:
3+
-- Measures end-to-end query time (planning + execution, warm) for a selective predicate
4+
-- on a NON-partition-key column, with table_range pruning on vs. off. Native PostgreSQL
5+
-- cannot prune on a non-key column, so without pruning the query scans every partition.
66
--
77
-- cargo pgrx run pg18
88
-- \i bench/planning_benchmark.sql
99
--
10-
-- Look at the "Planning Time" line and the number of child plans in each EXPLAIN.
10+
-- Pruning trades a small per-plan overhead (loading summaries + evaluating each
11+
-- partition) for skipping the scan of non-matching partitions, so it wins when the
12+
-- partitions it eliminates are large enough to outweigh that overhead.
1113

12-
\set part_count 1000
14+
\set part_count 100
15+
\set rows_per_part 30000
1316

1417
DROP TABLE IF EXISTS bench_events CASCADE;
15-
CREATE TABLE bench_events (region int, val bigint) PARTITION BY LIST (region);
18+
CREATE TABLE bench_events (region int, val bigint, pad text) PARTITION BY LIST (region);
1619

17-
-- One partition per region; each holds a disjoint 1000-wide band of `val`.
1820
SELECT format(
19-
'CREATE TABLE bench_events_%s PARTITION OF bench_events FOR VALUES IN (%s);',
20-
g, g
21-
)
21+
'CREATE TABLE bench_events_%s PARTITION OF bench_events FOR VALUES IN (%s);', g, g)
2222
FROM generate_series(1, :part_count) g \gexec
2323

24+
-- region is the partition key; val is a disjoint band per partition (the queried,
25+
-- non-key column).
2426
INSERT INTO bench_events
25-
SELECT g, (g * 1000) + s
26-
FROM generate_series(1, :part_count) g,
27-
generate_series(0, 49) s;
27+
SELECT g, g * 1000000 + s, repeat('x', 50)
28+
FROM generate_series(1, :part_count) g, generate_series(0, :rows_per_part - 1) s;
2829

29-
ANALYZE bench_events;
30+
VACUUM ANALYZE bench_events;
31+
CREATE INDEX bench_events_tr ON bench_events USING table_range (val);
3032

31-
SELECT table_range_create('bench_events'::regclass::oid, ARRAY['val']);
33+
\timing on
3234

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 ===================='
35+
-- Warm the relation cache first so the numbers reflect steady state, not first-touch
36+
-- partition-metadata loading (which both modes pay equally).
3937
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;
38+
SELECT count(*) FROM bench_events WHERE val = 50000000;
39+
40+
\echo '==================== pruning ON (warm) ===================='
41+
SELECT count(*) FROM bench_events WHERE val = 50000000;
42+
SELECT count(*) FROM bench_events WHERE val = 50000000;
4243

43-
\echo '==================== correctness check (must match) ===================='
4444
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;
45+
SELECT count(*) FROM bench_events WHERE val = 50000000;
46+
47+
\echo '==================== pruning OFF (warm) ===================='
48+
SELECT count(*) FROM bench_events WHERE val = 50000000;
49+
SELECT count(*) FROM bench_events WHERE val = 50000000;
4850

4951
DROP TABLE bench_events CASCADE;

0 commit comments

Comments
 (0)