Skip to content

Commit 5e26e77

Browse files
authored
Merge pull request #2 from bitner/summary-perf
Index-resident, incrementally-maintained summaries (BRIN-style); remove side table + REINDEX
2 parents 5cd6672 + aa8d29f commit 5e26e77

8 files changed

Lines changed: 721 additions & 455 deletions

File tree

README.md

Lines changed: 31 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -25,9 +25,10 @@ CREATE INDEX events_tr ON events USING table_range (val, created_at);
2525
-- Verify with EXPLAIN: non-matching partitions disappear from the plan.
2626
EXPLAIN (COSTS OFF) SELECT * FROM events WHERE val >= 250;
2727

28-
-- Recompute after heavy churn; or drop the summaries entirely.
28+
-- Inserts maintain the summary automatically; REINDEX only re-tightens after many
29+
-- deletes. DROP INDEX removes the summary with the index.
2930
REINDEX INDEX events_tr;
30-
DROP INDEX events_tr; -- removes the summaries it built
31+
DROP INDEX events_tr;
3132
```
3233

3334
The index is never used for scans — it exists only to build and own the summaries — so
@@ -49,17 +50,16 @@ EXPLAIN (COSTS OFF) SELECT * FROM places WHERE geom && ST_MakeEnvelope(0,0,10,10
4950

5051
## How it works
5152

52-
- **Summaries.** For each leaf partition and indexed column, one row in
53-
`table_range_summary` records the `has_nulls` / `all_nulls` flags plus either the
54-
column's btree `min`/`max` (scalar columns) or a single covering **extent** — a covering
55-
range for range types (`range_merge(range_agg(col))`) or the bounding box for PostGIS
56-
geometry (`ST_Extent(col)`).
57-
- **Planning.** A `planner_hook` loads all non-stale summaries once per top-level plan
58-
(a single query, cached for the duration of planning). A `set_rel_pathlist_hook` then
59-
evaluates each partition's restriction clauses against its cached summary and calls
60-
`mark_dummy_rel` on any partition that provably cannot match — eliminating it before
61-
child paths are generated. Wide partition trees therefore do not pay a per-partition
62-
lookup.
53+
- **Summaries live in the index.** Like BRIN, each leaf partition's summary is stored in
54+
that partition's index — one record per indexed column on the index's **metapage**, not
55+
in any side table. It holds the `has_nulls` / `all_nulls` flags plus either the column's
56+
btree `min`/`max` (scalar columns) or a single covering **extent** — a covering range
57+
for range types (`range_merge(range_agg(col))`) or the bounding box for PostGIS geometry
58+
(`ST_Extent(col)`).
59+
- **Planning.** For each partition the planner builds, a `set_rel_pathlist_hook` reads the
60+
summary from that partition's index (cached for the plan) and evaluates the partition's
61+
restriction clauses against it, calling `mark_dummy_rel` on any partition that provably
62+
cannot match — eliminating it before child paths are generated.
6363
- **Typed comparisons.** Min/max vs. constant comparisons use each column type's own
6464
btree compare function, so **any btree-comparable type works**: `bigint` / `int` /
6565
`smallint`, `numeric`, `real` / `double precision`, `text` / `varchar`, `date`,
@@ -69,12 +69,14 @@ EXPLAIN (COSTS OFF) SELECT * FROM places WHERE geom && ST_MakeEnvelope(0,0,10,10
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.** 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.
72+
- **Incremental maintenance (no REINDEX).** `aminsert` widens the summary in place as
73+
rows are inserted — the same way BRIN maintains its ranges. Because the summary only
74+
ever needs to be over-inclusive, these updates need no MVCC: an insert within the
75+
existing range writes nothing; one that extends it grows the min/max/extent. Pruning
76+
therefore stays correct **and** active across inserts without any rebuild. Deletes only
77+
shrink a partition's true range, leaving the summary conservatively wide (still safe);
78+
`VACUUM`/`REINDEX` can re-tighten it for selectivity. `DROP INDEX` removes the summary
79+
with the index's storage — there is no side table to clean up.
7880

7981
## Performance
8082

@@ -119,20 +121,20 @@ Everything not listed is conservatively **kept** (never mispruned):
119121
- `table_range.enable_pruning` (default `on`) — master switch.
120122
- `table_range.log_pruning_debug` (default `off`) — log each prune decision.
121123

122-
## Catalog
124+
## Storage
123125

124-
- `table_range_summary` — one summary row per (index, leaf partition, column):
125-
`index_oid`, `relid`, `attnum`, `kind` (`minmax` or `overlap`), `type_name`,
126-
`min_summary`, `max_summary`, `has_nulls`, `all_nulls`, `stale`, `tuple_version`.
126+
There is no catalog table — each partition's summary lives on its `table_range` index's
127+
metapage (block 0), written by `ambuild` and updated in place by `aminsert`, like BRIN.
127128

128129
## Project layout
129130

130131
| File | Responsibility |
131132
|------|----------------|
132-
| `src/lib.rs` | GUCs, `_PG_init`, catalog/bootstrap SQL, test wiring |
133-
| `src/summary_build.rs` | SPI summary build (scalar min/max + range/geometry extent) |
133+
| `src/lib.rs` | GUCs, `_PG_init`, test wiring |
134+
| `src/index_storage.rs` | per-index summary on the metapage: page I/O (Generic WAL) + (de)serialization |
135+
| `src/summary_build.rs` | build a leaf's summary by scanning its data (used by `ambuild`) |
134136
| `src/prune_hook.rs` | planner + pathlist hooks, per-plan cache, typed in-memory evaluation |
135-
| `src/index_am.rs` | `table_range` index access method + automatic operator-class provisioning |
137+
| `src/index_am.rs` | `table_range` index AM: build, incremental `aminsert` widening, opclass provisioning |
136138
| `src/e2e_tests.rs`, `src/index_am_tests.rs` | end-to-end tests |
137139

138140
## Building and testing
@@ -154,5 +156,6 @@ range-type tests, which exercise the same code path.
154156

155157
- `NOT IN` / `<> ALL`, `NOT (...)`, expression predicates, and parameterized
156158
prepared-statement plans are kept rather than pruned.
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`.
159+
- Inserts keep summaries current incrementally, but deletes only relax them (the summary
160+
can stay wider than the live data until a `VACUUM`/`REINDEX` re-tightens it) — always
161+
correct, just potentially less selective.

src/e2e_tests.rs

Lines changed: 0 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -237,37 +237,6 @@ fn e2e_large_tree_prunes_to_single_partition() {
237237
assert_eq!(count_big("val >= 3150"), 50); // p31 (3100..3199): 3150..3199
238238
}
239239

240-
#[pg_test]
241-
fn e2e_per_plan_cache_loads_once_regardless_of_partitions() {
242-
// 64 range partitions; planning a query must load summaries exactly once, not once
243-
// per partition — the observable signature of the per-plan cache.
244-
Spi::run(
245-
"DROP TABLE IF EXISTS cache_t CASCADE;
246-
CREATE TABLE cache_t (val bigint) PARTITION BY RANGE (val);",
247-
)
248-
.unwrap();
249-
for i in 0..64 {
250-
let lo = i * 100;
251-
let hi = lo + 100;
252-
Spi::run(&format!(
253-
"CREATE TABLE cache_t_p{i} PARTITION OF cache_t FOR VALUES FROM ({lo}) TO ({hi});
254-
INSERT INTO cache_t SELECT g FROM generate_series({lo}, {hi} - 1) g;"
255-
))
256-
.unwrap();
257-
}
258-
e2e_build("cache_t", "val");
259-
e2e_set_pruning(true);
260-
261-
Spi::run("SELECT table_range_reset_cache_load_count()").unwrap();
262-
let found = Spi::get_one::<i64>("SELECT count(*)::bigint FROM cache_t WHERE val = 3333")
263-
.unwrap()
264-
.unwrap();
265-
assert_eq!(found, 1);
266-
let loads = Spi::get_one::<i64>("SELECT table_range_cache_load_count()")
267-
.unwrap()
268-
.unwrap();
269-
assert_eq!(loads, 1, "expected exactly one summary load for the plan, got {loads}");
270-
}
271240

272241
/// True if PostGIS can be created in this environment. Checked via the catalog so a
273242
/// missing extension does not abort the test transaction.

0 commit comments

Comments
 (0)