Skip to content

Commit a0143a0

Browse files
bitnerclaude
andcommitted
Consolidate on the index access method; auto-provision opclasses
Make CREATE INDEX ... USING table_range the single interface and remove the SQL function interface (table_range_create/refresh/drop/summary_count), the registration table, and the staleness triggers. - Operator classes are now provisioned automatically by mirroring the types that already have a btree or range operator class, re-running on every CREATE EXTENSION. So any btree-comparable type and any range type work out of the box, and PostGIS geometry registers the moment `CREATE EXTENSION postgis` runs — no manual step. - summary_build.rs keeps only the ambuild path (scalar min/max + range/geometry extent); staleness is maintained by aminsert, recompute via REINDEX; the sql_drop event trigger still cleans up dropped indexes/tables. - Tests rewritten onto CREATE INDEX (26 pass on pg18); dropped the two function-only tests (refresh needs REINDEX which can't run in a pg_test txn; drop is covered by the AM drop test). - README/benchmark updated. Honest planning-time note: owning summaries in a real index adds per-partition index-metadata overhead during planning (~85 ms flat on a bare 1000-partition table); pruning still removes ~110 ms of child-path planning. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent ba93502 commit a0143a0

6 files changed

Lines changed: 166 additions & 449 deletions

File tree

README.md

Lines changed: 39 additions & 38 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,12 +69,12 @@ 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

@@ -84,10 +84,15 @@ paths for every partition. On a 1000-partition table queried by a non-key column
8484

8585
| | Planning time | Result |
8686
|---|---|---|
87-
| pruning off | ~125 ms | 50 rows |
88-
| pruning on | ~17 ms | 50 rows |
87+
| pruning off | ~210 ms | 50 rows |
88+
| pruning on | ~100 ms | 50 rows |
89+
90+
Pruning removes ~110 ms of child-path planning here. Note the absolute numbers are higher
91+
than they could be: because summaries are owned by a real index, PostgreSQL loads index
92+
metadata for every partition during planning (a flat overhead, ~85 ms on this bare
93+
1000-partition table — proportionally smaller when partitions already carry indexes).
8994

90-
Summaries are loaded **once per plan** (not per partition); the
95+
Summaries themselves are loaded **once per plan** (not per partition); the
9196
`e2e_per_plan_cache_loads_once_regardless_of_partitions` test asserts exactly one
9297
catalog load for a 64-partition query.
9398

@@ -114,20 +119,18 @@ Everything not listed is conservatively **kept** (never mispruned):
114119

115120
## Catalog
116121

117-
- `table_range_summary` — one summary row per (owner, leaf partition, column):
122+
- `table_range_summary` — one summary row per (index, leaf partition, column):
118123
`index_oid`, `relid`, `attnum`, `kind` (`minmax` or `overlap`), `type_name`,
119124
`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.
122125

123126
## Project layout
124127

125128
| File | Responsibility |
126129
|------|----------------|
127130
| `src/lib.rs` | GUCs, `_PG_init`, catalog/bootstrap SQL, test wiring |
128-
| `src/summary_build.rs` | SPI summary build/refresh/drop, registration, staleness triggers |
131+
| `src/summary_build.rs` | SPI summary build (scalar min/max + range/geometry extent) |
129132
| `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 |
133+
| `src/index_am.rs` | `table_range` index access method + automatic operator-class provisioning |
131134
| `src/e2e_tests.rs`, `src/index_am_tests.rs` | end-to-end tests |
132135

133136
## Building and testing
@@ -149,7 +152,5 @@ range-type tests, which exercise the same code path.
149152

150153
- `NOT IN` / `<> ALL`, `NOT (...)`, expression predicates, and parameterized
151154
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.
155+
- Summaries are exact at build time; an insert that extends a partition marks it stale
156+
(not pruned, but still correct) until the next `REINDEX`.

bench/planning_benchmark.sql

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ FROM generate_series(1, :part_count) g,
2828

2929
ANALYZE bench_events;
3030

31-
SELECT table_range_create('bench_events'::regclass::oid, ARRAY['val']);
31+
CREATE INDEX bench_events_tr ON bench_events USING table_range (val);
3232

3333
\echo '==================== pruning OFF ===================='
3434
SET table_range.enable_pruning = off;

0 commit comments

Comments
 (0)