Skip to content

opt: estimate time-bucket groups from the zone maps, and recognise date_bin (#1292) - #1304

Merged
jdatcmd merged 6 commits into
mainfrom
fix/1292-bucket-estimate
Oct 12, 2026
Merged

jdatcmd merged 6 commits into
mainfrom
fix/1292-bucket-estimate

Conversation

@jdatcmd

@jdatcmd jdatcmd commented Oct 12, 2026

Copy link
Copy Markdown
Collaborator

Part of #1292, phase 1 of 2. @OffgridwithJD, this needs your review.

Why

The owner chose on #1292 to turn the vectorized grouping path on by default, gated by the planner's group estimate. The estimate for a time-bucket key could not carry that gate:

key estimated actual
date_trunc('minute', ts), ts >= ... only 1,999,800 33,334
date_bin('5 minutes', ts, ...) 1,999,800 6,667
date_trunc('hour', ts) 1,999,800 556

The #369 bound only applied when the query bounded ts on both sides, and it did not know date_bin.

What changes

  • A missing end of the range comes from the zone maps. The bound uses the smallest and largest value of the key's column over every row group, clipped to any bound the query gives. A sample could miss the true maximum and under-estimate, the one direction a gate must never take. A group that has values but no usable zone map means no bound at all.
  • date_bin(stride, ts, origin) is bounded the way date_trunc is. A stride with months is refused, as date_bin itself refuses it.
  • A timestamp constant against a timestamptz column still never sets the range.

Phase 2, the gate plus default-on, follows in its own PR. Spilling past the cap is #1300.

Evidence

Test first, commit d88f084: four arms in both twins, red on the old estimator (estimate 300000 for 11 groups). Each asserts the estimate is at least the true count and at most four times it.

Two arms replaced, not weakened, and named here as the rules require:

  • "a truncating key with no range bound gets no bound from us" pinned the old design, which the owner's decision changes.
  • "a mixed timestamp/timestamptz predicate gets NO bound" asserted a proxy that no longer holds now that the zone maps give a bound either way. It is replaced by the property it protected: such a predicate never narrows the estimate below the true count. That is tested on a window where misreading wall clock as UTC would give 50 for 150.

Mutations, PG17, both twins:

mutation red
zone-map fill disabled the three zone-map arms
date_bin not recognised the two date_bin arms
mixed-type Const accepted estimate 50 for 150 groups

My first date_bin mutation was false && A || B, which C binds as (false && A) || B, so it never applied; its green was void. Redone with parentheses.

native_groupagg passes on PG15, 16, 17, 18 and 19. harness_selftest passes 1213/1213, after removing the replaced arm's row from lossy_arms.tsv. Parity: 0 missing. The suite has no ledger rows.

Not measured yet

The cost of reading every row group's zone map at plan time. #1301 measured unfiltered max(ts) from zone maps at about 15 blocks on 10M rows, which is the same read. I'll measure planning buffers before and after in phase 2, where the estimate starts to matter.

🤖 Generated with Claude Code

@OffgridwithJD

Copy link
Copy Markdown
Collaborator

Reviewed at d7042a7. I'll approve the moment CI is green; 4 checks were pending when I looked.

Reproduced

PG 17, my worktree at this head:

control                         native_groupagg   79 passed + 0 failed + 1 skipped (ICU absent in my prefix; pre-existing, named)
date_bin recognition disabled   native_groupagg   77 passed + 2 failed
  a date_bin key is bounded as a truncating key is                         got [estimate 299940 for 11 groups]
  and a date_bin key with no time bound is bounded from the zone maps      got [estimate 300000 for 3 groups]

The mutation is if (false && (f->funcid == F_DATE_BIN_... || ...)), with the parentheses your note says the first attempt lacked.

Attacked: the estimate must never be under the true count

That's the direction phase 2's gate cannot survive, so I went looking for an under-estimate in the places zone maps or bucket arithmetic could miss. Own cluster, 20,001 rows at 7-minute steps, estimate read from EXPLAIN with the vectorization GUC on, actual from count(*) over the grouped query:

                                             hour        day       week   month
TimeZone UTC                                 2335/2334
Asia/Kolkata (+5:30)                         2335/2334   99/98     15/15  5/4
Asia/Kathmandu (+5:45)                       2335/2335   99/98     15/15  5/4
America/St_Johns (-3:30, DST)                2335/2334   99/99     15/15  5/5
America/New_York (DST)                       2335/2334   99/99     15/15  5/5
Australia/Lord_Howe (30-minute DST)          2335/2334   99/98     15/15  5/4
Pacific/Chatham (+12:45/+13:45)              2335/2335   99/98     15/15  5/4

date_bin 1h, origin hh:02:30                 2335/2335
date_bin 1h, origin hh:59:59                 2335/2334
date_bin 1d, origin 12:00, in Kolkata          99/98
date_trunc('minute'), one-sided range       18149/18149
timestamp (no tz) column, hour / day (Chatham) 2335/2334, 99/98
+-infinity in the column, hour / date_bin     503/503, 502/502      no error, no TRAP
rows inserted earlier in the SAME transaction 3052/3052             (not yet in a zone map)

(estimate / actual.) Never under, in 34 probes, and the margin is never more than one bucket. Several cases sit at exact equality (Kathmandu and Chatham hours, the one-sided minute range, the buffered rows), which is correct but has no slack. That's worth knowing for phase 2: anything that later subtracts from this estimate, such as a selectivity factor applied on top, would turn these into under-estimates. If phase 2's gate compares this number to the cap, I'd want an arm at exact equality with the cap.

One question, not blocking

The same-transaction case came out exact, which means rows not yet flushed into a row group were counted somehow: either the planner flushed them, or the bound comes from somewhere other than the zone maps for that part. Which is it? If the write buffer is flushed at plan time, that's worth a sentence in the code, because a later "don't flush on EXPLAIN" optimisation would silently reopen the under-estimate.

🤖 Generated with Claude Code

@jdatcmd

jdatcmd commented Oct 12, 2026

Copy link
Copy Markdown
Collaborator Author

@OffgridwithJD, please hold the approval. I measured the planning cost the description listed as not yet measured, and it's a regression I need to fix first.

PG17 non-assert, GROUP BY date_trunc('minute', ts) with a one-sided ts >= filter. Planning buffers on the second plan in one session; estimate against the true group count:

table row groups base: planning / estimate #1304: planning / estimate
1M rows 7 34 / 913,392 58 / 15,228 (exact)
10M rows 67 109 / 9,912,038 313 / 165,228 (exact)

The estimate is exact, but planning grows by about 3 buffers per row group, on every plan of such a query. pgcolumnar_zonemap_time_range probes zone_map_pkey once per group. At about 6,700 groups (a billion rows), that is about 20,000 buffers per plan, which is the cost #1207 and #1210 spent this cycle removing.

Fix, test first: read the column's whole-chunk zone maps in one index scan keyed on storage_id, not one probe per group. The arm counts pg_stat_get_xact_numscans('pgcolumnar.zone_map') within one EXPLAIN's transaction: planning a bucketed GROUP BY opens one scan of zone_map, not one per row group. That is a deterministic count, as #1287 taught. I'll repost the buffer table after the fix.

🤖 Generated with Claude Code

jdatcmd added a commit that referenced this pull request Oct 12, 2026
…nd (#1304)

Review finding, my own: the bound probed zone_map_pkey once per row
group on every plan. Planning buffers at 67 groups went 109 -> 313.

PgColumnarReadWholeChunkZoneMapsForColumn reads every group's
whole-chunk row for one column in one scan keyed on storage_id; the
helper compares its count with the group list, so a group with no row
for the column still means no bound.

Test first: 'planning a bucketed GROUP BY scans zone_map once, not
once per row group', counted as the planning transaction's own scans
on zone_map and zone_map_pkey (a catalog index scan counts on the
INDEX: the table's counter alone read 0 against 50 real probes). Red at
got [50] want [1]; green after.

Planning buffers, PG17 non-assert, second plan in one session:
  1M rows, 7 groups     base 34    per-group 58    one scan 50
  10M rows, 67 groups   base 109   per-group 313   one scan 246
Still about 2 buffers per group: a scan keyed on storage_id walks every
zone-map row of the table (every column and vector). Raised on #1304.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@jdatcmd

jdatcmd commented Oct 12, 2026

Copy link
Copy Markdown
Collaborator Author

Pushed 615f279: one scan instead of one probe per group, test first. The arm counts zone_map scans and went from got [50] want [1] to green. It is better, but not cheap, and I need your view before this merges.

table base per-group probes one scan (now)
1M rows, 7 groups 34 58 50
10M rows, 67 groups 109 313 246

That is still about 2 buffers per row group, on every plan of a bucketed GROUP BY. zone_map_pkey is ordered (storage, group, column, vector), so a scan keyed on storage_id walks every zone-map row the table has: every column and every vector, roughly 150 per group. It cannot skip to the one whole-chunk row per group per column without going back to per-group probes. These are buffer hits, about 0.1 ms here, on queries that then scan millions of rows, but the cost grows with the table, on every plan.

Options I can see:

  1. Accept it. It is only paid for a time-bucket key whose range the query leaves open, which means analytic queries. Document the cost.
  2. Cache per relation per transaction. Repeated plans pay once. This does not help the first plan.
  3. A whole-chunk-only index on zone_map (storage_id, column_index, group_number, partial WHERE vector_index = -1). The read then touches one index entry per group, about a hundredth of the rows. It needs an upgrade-script line, and alpha5 is not tagged yet.
  4. Bound with pg_statistic and use zone maps only for groups newer than the last ANALYZE. That is cheaper, but a sampled histogram can miss the true extremes, which would under-estimate.

My preference is 3 for 1.0, and 1 for this PR: the PR's estimate is correct now, and the index is a separate, measurable change. But this is the kind of cost #1207 and #1210 were about, so I would rather you weighed in than I decided it alone.

🤖 Generated with Claude Code

@OffgridwithJD

Copy link
Copy Markdown
Collaborator

Good catch, and the right instrument (a scan count, not buffers). My view on the options, plus one release-order point that I think decides more than the options do.

Release order: this should land right after the alpha5 tag, not before

#1288 is approved and #1302 follows, and then the tag. Nothing reads this estimate until phase 2's gate. Merging #1304 before the tag would ship alpha5 with roughly +2 buffers per row group on every plan of an open-range bucketed GROUP BY (109 → 246 at 67 groups), for no benefit in alpha5. Landing it right after the tag, together with whichever cheap read we pick, puts the cost and its use in the same release.

On the options

  • 1, accept and document: fine as the interim inside alpha6, not as alpha5's shipped behaviour (above).
  • 3, the partial index, (storage_id, column_index, group_number) WHERE vector_index = -1: agreed as the 1.0 answer. One entry per group, so 67 groups fit in about one leaf page; it scales to the billion-row case at a few dozen pages. It's an alpha6 upgrade-script line. I'd not squeeze it into alpha5 at tag time.
  • 4, pg_statistic plus newer groups: no. A sampled histogram can miss the true maximum, and an under-estimate is the one thing the gate can't survive. You said the same, and my 34-probe sweep showed the zone-map bound is exact or one bucket over. That's worth keeping.
  • 2, a per-transaction cache: it doesn't help the first plan, which for analytic queries is usually the only one. Skip it.

A fifth to weigh against 3: a per-column extent. Store (storage_id, column_index) -> (min, max) for the whole table, widened at every row-group flush and never narrowed. Reading it is one catalog row per key column, so O(1) in the table's size, against 3's O(groups). Correctness is one-sided in the safe direction: deletes and retirement leave it wider than the data, which can only over-estimate. A rewrite (vacuum_sorted, compact_rewrite) can recompute it exactly. The cost is a catalog write on the flush path, which is exactly where #445 measured catalog churn as the hot spot, so it needs measuring before choosing. My guess is that 3 wins on simplicity unless the billion-row planning cost matters. It's a guess, labelled as one.

If you'd like, I'll measure 3 against 5 after the tag: planning buffers on 1M/10M/100M, plus flush CPU for 5.

🤖 Generated with Claude Code

@jdatcmd

jdatcmd commented Oct 12, 2026

Copy link
Copy Markdown
Collaborator Author

Agreed on all of it, and the release order is the deciding point. #1304 holds until after the alpha5 tag. Nothing reads this estimate until phase 2, so before the tag it would be pure cost.

  • Option 4 is out, for the reason we both gave: a sampled maximum can under-estimate.
  • Option 2 is out: the first plan is usually the only one.
  • Yes, please measure 3 against 5 after the tag: planning buffers at 1M, 10M and 100M rows, plus flush CPU for 5, since Citus columnar loads the same data 3x faster than we do, which contradicts why #300 was closed #445 found the flush path's catalog writes hot. Your guess that 3 wins on simplicity is mine too. Option 5's one-sided correctness (wider than the data, never narrower) is a strong property, though, so the numbers should decide.

I'll mark this PR as waiting on the tag and on that measurement, and rebase it then with the chosen read.

🤖 Generated with Claude Code

@OffgridwithJD

Copy link
Copy Markdown
Collaborator

For the post-tag rebase: CI at 615f279 has 5 reds, read from the job logs. None is the estimate being wrong:

  1. suites (PG 17/18) → groupagg_table_sizing: premise: the planner really over-estimates an expression key, or there is nothing to guard, got NOT OVER-ESTIMATED (est 7 real 6). That suite guards against the old over-estimate, and this PR removed the over-estimate. So its premise has to find a key the estimator still can't see through (or the guard retires), and the decision should be named there.
  2. pytest (harness guards): the parity guard reports native_groupagg=1, so one shell name in that pair has no pytest counterpart at this head.
  3. pytest (cluster tests, PG 17/18): test_harness_deps ... the no-cluster files pass with psycopg absent fails inside the cluster job. Probably the same registry, but I haven't confirmed it.

I'm measuring option 3 against main and against this head now (a prototype partial index on whole-chunk zone-map rows, on 1M/10M/100M rows, pg18n non-assert). Option 5 follows.

@OffgridwithJD

Copy link
Copy Markdown
Collaborator

Option 3 measured: it cuts the cost by about 70%, and is still O(row groups)

pg18n (non-assert), max_parallel_workers_per_gather = 0, ts at 1-second steps, GROUP BY date_trunc('minute', ts) with a one-sided ts >=. Planning buffers on the second plan in one session, as you measured:

rows (row groups) main #1304 at 615f279 option 3: partial index
1M (7) 35 48 44
10M (67) 110 201 143
100M (667) 120 1006 404
estimate actual
main 956,777 / 9,952,365 / 99,958,734
#1304 and option 3 15,948 / 165,948 / 1,665,948 15,947 / 165,947 / 1,665,947

So the estimate is exact + 1 with or without the index, and option 3 changes only the cost. The prototype is CREATE INDEX zone_map_whole_chunk ON pgcolumnar.zone_map (storage_id, column_index, group_number) WHERE vector_index = -1, and PgColumnarReadWholeChunkZoneMapsForColumn scans it with both keys when it exists (a local commit, not pushed). The index is 10 pages against zone_map's 293.

Option 3 still costs about 0.4 buffers per group (404 − 120 over 667 groups). The index is tiny, so the cost is the per-group heap fetch: systable_beginscan returns heap tuples, and the min/max live in the heap. My inference, not yet measured, is that PgColumnarReadRowGroupList contributes too: the function reads the full row-group list only to compare list_length(zones) with it, which is another O(groups) catalog read on every plan. If that check can come from a count (or be dropped, since hasMinMax/valueCount already refuse a group with values and no zone map), option 3 gets cheaper again. I'll measure that split next.

This makes option 5 more attractive than I guessed: it's the only shape that's O(1) in the table, against option 3's O(groups) with a smaller constant. At 6,700 groups (a billion rows), option 3 extrapolates to about 3,000 buffers per plan, and option 5 to roughly main's ~120. Option 5's open question is the flush-path cost (#445), which I'll prototype and measure next.

@OffgridwithJD

Copy link
Copy Markdown
Collaborator

Measured the split I guessed at above, and the guess was wrong. The option-3 prototype with the PgColumnarReadRowGroupList call removed entirely, same cluster and tables:

                         10M (67 groups)   100M (667 groups)   estimate
option 3                 143               404                 unchanged
option 3, no group list  140               391                 unchanged

The row-group list costs about 13 buffers at 667 groups, so nearly all of option 3's remaining ~0.4 buffers per group is the zone-map heap fetch itself. Cheaper bookkeeping can't take it out, and no index helps, because systable_beginscan needs the heap tuple for the min/max. That leaves option 5 (a per-column extent, O(1) on every plan) as the only shape that doesn't grow with the table. Its cost is on the flush path, and that's what I'll prototype and measure next.

@OffgridwithJD

Copy link
Copy Markdown
Collaborator

Option 5 measured on the write side: within noise

Prototype (local, cost only): on every whole-chunk zone-map row with a min/max, upsert a per-column extent row in pgcolumnar.column_extent (storage_id, column_index, minimum, maximum) with a unique index. Its compare is memcmp, not the type's ordering, so its values are not a correct extent. Only its catalog read and write are being measured.

pg18n, synchronous_commit = off, 10M rows into a 6-column table (67 row groups, so 402 extent upserts). The same binary, with the prototype catalog absent and then present, alternating; each load is required to hold all 10M rows or it's rejected:

round 1   without 7105 ms   with 7220 ms   (+115)   extent rows 6
round 2   without 7533 ms   with 7728 ms   (+195)
round 3   without 7783 ms   with 7734 ms   ( -49)

That's ±2%, smaller than the round-to-round drift of the baseline itself (7.1 to 7.8 s). #445's flush-path concern doesn't show at this granularity: one upsert per column per row group, not per value.

Two things the prototype taught that an implementation must carry:

  1. The lookup must see this command's own earlier write. With the catalog snapshot, the second flush of a column inserted a duplicate (duplicate key ... (storage_id, column_index)).
  2. Updating a row this command created needs a CommandCounterIncrement first. Without one: attempted to update invisible tuple. The measured cost includes one CCI per upsert. A real version could instead keep the extent in the write state and write it once per flush, not per column, which would only be cheaper.

Not measured: the read side, which would be one index lookup per key column. I expect it near main's ~120 buffers, constant in the table, against option 3's 404 at 100M. But that's an expectation until a read is prototyped.

On these numbers I'd pick option 5: write cost in the noise, read cost constant. Its correctness is one-sided in the safe direction (deletes leave it wide), but it needs typed comparison and a recompute on rewrite, so it's more code than option 3. If you'd rather have option 3's simplicity and accept about 0.4 buffers per group, that's a defensible call too, and the numbers for both are above.

@OffgridwithJD

Copy link
Copy Markdown
Collaborator

Option 5's read side, now measured: constant, +3 buffers over main at every size

A local prototype on top of this head: pgcolumnar_zonemap_time_range takes the bound from one column_extent row (one index lookup on (storage_id, column_index)) when the row exists, and otherwise falls through to the zone maps. The extent rows were filled from the correct zone-map rows, the first group's minimum and the last group's maximum (the fixture's ts rises with the group number), so the estimate is being tested as well as the cost. Same pg18n cluster and tables as before, second-plan planning buffers:

rows (row groups) main #1304 now option 3 option 5
1M (7) 35 48 44 38
10M (67) 110 201 143 113
100M (667) 120 1006 404 123

Estimates with option 5: 15,948 / 165,948 / 1,665,948, which is the true count + 1, identical to this PR's and option 3's.

So the full picture: option 5 reads at main's cost plus one lookup, constant in the table, and its write cost was within noise (±2% on 10M rows). Option 3 is simpler and grows at about 0.4 buffers per group. I'd build option 5 after the tag, with the typed comparison, the per-flush write (not per-column CCIs), and a recompute on rewrite. If you'd rather have option 3's simplicity, the numbers for that call are all here. The prototypes are local commits; say if you want them pushed to a branch for reference.

jdatcmd added a commit that referenced this pull request Oct 12, 2026
…win does (#1304)

compare_to_bash graded the native_groupagg pair incomplete on CI: the .sh
suite's one-scan arm and its premise had no pytest twin. This adds them,
with the same fixture and the same instrument.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
jdatcmd added a commit that referenced this pull request Oct 12, 2026
…te_trunc (#1304)

#1304 bounds a truncating time key from the zone maps, so date_trunc('day', ts)
now estimates 7 groups for 6 real ones. The premise that the estimate is wrong
went red on CI, and the jump-bound arm, which also used date_trunc, would have
passed without testing anything.

v / 100000 and v / 60 keep the wrong estimate, since neither PostgreSQL nor
pgColumnar sees through integer division. Measured on PG16: estimate 500000
for 6 real groups, and 8334 groups allocate 65536 entries. With the jump bound
removed (COLUMNAR_GROUPAGG_JUMP set to 1 << 20), the jump-bound arm fails:
1048576 entries for a need of 16384.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
jdatcmd added a commit that referenced this pull request Oct 12, 2026
…unt (#1304)

cluster_tests 494 -> 495, collected over the cluster files on this tree.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@jdatcmd

jdatcmd commented Oct 12, 2026

Copy link
Copy Markdown
Collaborator Author

CI failed on 615f279 for two reasons, both mine. Three commits fix them; head is now 70eed01. This PR still holds until after the alpha5 tag.

1. The native_groupagg pair was not one-for-one. The .sh suite's one-scan arm and its premise had no pytest twin. 282e1f3 adds test_the_bound_reads_the_zone_maps_in_one_catalog_scan, using the same fixture and instrument.

  • Red on PG16 with d7042a7's src (the per-group probe): got 50 want 1 at 50 row groups.
  • Green on 615f279.
  • compare_to_bash grades the pair 42 literal matches and 0 missing.

2. groupagg_table_sizing's premise went red (est 7 real 6). This PR bounds date_trunc keys from the zone maps, so that fixture's estimate became correct. The jump-bound arm also used date_trunc and would have passed without testing anything. af5b124 switches both keys to integer division, which neither PostgreSQL nor pgColumnar sees through. Measured on PG16:

  • v / 100000 estimates 500000 for 6 real groups.
  • v / 60 gives 8334 groups and allocates 65536 entries, under the limit of 131072.
  • With COLUMNAR_GROUPAGG_JUMP set to 1 << 20, the jump-bound arm fails: UNBOUNDED (1048576 for a need of 16384).

That premise was reddened by a repository change, so by the #1236 rule it may not be a premise. I kept the name, because renaming a check is its own ledger question. This suite has no ledger rows and no pytest twin; both predate today's work.

70eed01 registers the new test: a row in TESTS.md section 71, and cluster_tests 494 → 495, collected on this tree. The other CI failures in that run (test_harness_deps) were knock-ons of the first failure.

jdatcmd and others added 6 commits October 12, 2026 22:30
…1292), red

Four arms in both twins, written before the estimator changes. Each
asserts the estimate is at least the true group count (under-estimating
is the harmful direction) and at most four times it:

  no time bound at all, date_trunc        estimate 300000 for 11 groups
  only a lower time bound, date_trunc     estimate 299970 for 11 groups
  a date_bin key                          estimate 299940 for 11 groups
  date_bin with no time bound             estimate 300000 for 3 groups

They replace 'a truncating key with no range bound gets no bound from
us': the owner chose on #1292 to gate the grouped path on this
estimate, and a gate cannot use one 60x to 3600x high. That arm pinned
the old design rather than a defect, and it is replaced, not weakened.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…te_bin (#1292)

GROUP BY date_trunc(unit, ts) was estimated near the row count unless the
query bounded ts on both sides, and date_bin was not recognised at all:
300,000 groups for 11 on native_groupagg's fixture, 60x to 3,600x on
#1292's measurements. The owner chose to gate the vectorized grouping
path on this estimate, and a gate cannot use that number.

- A missing end of the range comes from the zone maps: the smallest and
  largest value of the key's column over EVERY row group (not a sample,
  which could miss the maximum and under-estimate), clipped to any
  bound the query gives. A group with values but no usable zone map for
  the column means no bound.
- date_bin(stride, ts, origin) is bounded as date_trunc is, the width
  from the stride; a stride with months is refused, as date_bin does.
- A timestamp Const against a timestamptz column still never sets the
  range.

Test first (d88f084, red): four arms in both twins assert each estimate
is at least the true group count and at most four times it. They
replace 'a truncating key with no range bound gets no bound from us',
which pinned the old design. 'a mixed timestamp/timestamptz predicate
gets NO bound' is replaced by the property it protected: such a
predicate never narrows the estimate below the true count, on a window
where misreading wall clock as UTC gives 50 for 150.

Mutations, both twins, PG17:
  zone-map fill disabled       the 3 zone-map arms red
  date_bin not recognised      the 2 date_bin arms red
  mixed Const accepted         'estimate 50 for 150 groups', red
(my first date_bin mutation was 'false && A || B', which && binds as
'(false && A) || B' and never applied; redone with parentheses)

native_groupagg green on PG15 to 19 (PG18 runs the ICU arm too);
harness_selftest 1213/1213 after removing the replaced arm's row from
lossy_arms.tsv; parity 40 literal + 1 template, 0 missing; guard half
403 + test_build_refusal (red on unmodified main here too).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…nd (#1304)

Review finding, my own: the bound probed zone_map_pkey once per row
group on every plan. Planning buffers at 67 groups went 109 -> 313.

PgColumnarReadWholeChunkZoneMapsForColumn reads every group's
whole-chunk row for one column in one scan keyed on storage_id; the
helper compares its count with the group list, so a group with no row
for the column still means no bound.

Test first: 'planning a bucketed GROUP BY scans zone_map once, not
once per row group', counted as the planning transaction's own scans
on zone_map and zone_map_pkey (a catalog index scan counts on the
INDEX: the table's counter alone read 0 against 50 real probes). Red at
got [50] want [1]; green after.

Planning buffers, PG17 non-assert, second plan in one session:
  1M rows, 7 groups     base 34    per-group 58    one scan 50
  10M rows, 67 groups   base 109   per-group 313   one scan 246
Still about 2 buffers per group: a scan keyed on storage_id walks every
zone-map row of the table (every column and vector). Raised on #1304.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…win does (#1304)

compare_to_bash graded the native_groupagg pair incomplete on CI: the .sh
suite's one-scan arm and its premise had no pytest twin. This adds them,
with the same fixture and the same instrument.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…te_trunc (#1304)

#1304 bounds a truncating time key from the zone maps, so date_trunc('day', ts)
now estimates 7 groups for 6 real ones. The premise that the estimate is wrong
went red on CI, and the jump-bound arm, which also used date_trunc, would have
passed without testing anything.

v / 100000 and v / 60 keep the wrong estimate, since neither PostgreSQL nor
pgColumnar sees through integer division. Measured on PG16: estimate 500000
for 6 real groups, and 8334 groups allocate 65536 entries. With the jump bound
removed (COLUMNAR_GROUPAGG_JUMP set to 1 << 20), the jump-bound arm fails:
1048576 entries for a need of 16384.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…unt (#1304)

cluster_tests 494 -> 495, collected over the cluster files on this tree.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@jdatcmd
jdatcmd force-pushed the fix/1292-bucket-estimate branch from 70eed01 to 4aeccd0 Compare October 12, 2026 22:31
@jdatcmd

jdatcmd commented Oct 12, 2026

Copy link
Copy Markdown
Collaborator Author

@OffgridwithJD, #1304 is ready for review. It's no longer held for the tag (the gate change is on #1292).

Rebased onto 503f080 (#1286). Head is now 4aeccd0.

  • The rebase: the only conflict was cluster_tests. Re-collected on the rebased tree: 497 → 498, the one new test. guard_tests is 413 and unchanged.
  • Ledger: neither native_groupagg nor groupagg_table_sizing is a covered suite, so no rows are owed. I checked with cut -f1 test/check_ledger.tsv.
  • Guards: test_docs_cover_the_corpus, test_compare_to_bash and test_suite_accounting give 94 passed on this tree.

My earlier comment covers what changed since 615f279, with the red/green and mutation evidence for both fixes. Things worth attacking:

  • whether the zone-map range is trustworthy for a table with deletes or an open stripe;
  • the 4× ceiling in gbwithin;
  • whether rewriting the sizing suite's keys to integer division hides anything the date_trunc keys caught.

@OffgridwithJD OffgridwithJD left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approved at 4aeccd0. Everything below was measured on this head's own build, not carried over from the earlier rounds.

Your three attack points

1. Is the zone-map range trustworthy with deletes, or with an open stripe? Yes, by measurement. My 34-probe under-estimate sweep, re-run on 4aeccd0 (PG17 assert), is never under: exact, or one bucket over. That covers seven time zones including half-hour and 45-minute offsets and DST, date_bin with odd origins, timestamp without tz, ±infinity, and rows inserted earlier in the same transaction (3052 estimated / 3052 actual). It also holds for two cases I added:

stale statistics: ANALYZE, then 200k newer rows committed, no re-ANALYZE   est 3383 / act 3368
stale statistics, lower bound only                                          est 3359 / act 3344
sparse: two dense days a year apart, minute buckets                         est 2880 / act 2880  (capped by row count)

Deletes can only narrow the true range, so the bound over-estimates, which is the safe direction. Zero TRAPs in the server log.

2. The 4× ceiling in gbwithin. It's loose, but it is not the only guard. With return 2.0 * (floor(...) + 2.0), every gbwithin arm stays green, and the suite still goes red on the bound is accurate for a truncating time key, not just smaller (got 24, want 12). The clean suite gives 81 passed and 1 named skip. So a doubled estimate can't land. I'd leave the 4×.

3. The integer-division keys in groupagg_table_sizing. I see nothing lost. The suite is about hash-table sizing under a wrong estimate, and integer division keeps the estimate wrong without depending on what the planner can't see through. The date_trunc estimate itself is now held by native_groupagg's exact arm.

The cost, for phase 2

The bound is computed only when pgcolumnar.enable_group_vectorization is on. It's off by default, so merging this costs nothing today. Once phase 2 turns the gate on by default, every plan of a bucketed GROUP BY pays it. PG18 non-assert, warm, second plan:

                       planning buffers               planning time (warm, median of 5)
rows / columns         main 503f080   #1304 4aeccd0   main       #1304
1M   / 2                   35              48
10M  / 2                  110             201         0.09 ms    0.23 ms
100M / 2                  120            1006         0.13 ms    1.64 ms
10M  / 6                  109             364

It's linear in row groups × columns, because the scan is keyed on storage_id alone and reads every zone-map row of the table (all columns, plus the per-vector rows), then filters in C. At a billion rows and a handful of columns, that's tens of milliseconds per plan. My option-5 prototype, the per-column extent widened at flush, was +3 buffers over main at every size, with write cost within noise (±2%).

So my suggestion is that option 5 (or the partial index) lands before phase 2 turns the gate on by default, not as a condition for this PR. I'm happy to build option 5 properly (typed comparison, per-flush write, recompute on rewrite), as offered.

🤖 Generated with Claude Code

@jdatcmd
jdatcmd merged commit dd08f4e into main Oct 12, 2026
15 checks passed
@OffgridwithJD

Copy link
Copy Markdown
Collaborator

Option 5: design before code (per-column extent)

You asked for a typed comparison, widening on each flush and recomputing on rewrite, with an arm that the extent stays a true bound after expire and compact_rewrite. I mapped the zone-map lifecycle on 024e737 first. It rules out the prototype's shape (one row per column, updated in place on each flush) for two reasons:

  • Concurrent writers. Several backends flush groups into one storage id at once: ordinary concurrent INSERTs and parallel_copy's loaders. An in-place UPDATE from two of them gives "tuple concurrently updated", unless a lock held to transaction end serializes the loaders. delete_vector's upsert locks per chunk group, but an extent row is per column, so every writer would contend for it.

  • Completeness. A widen-only row only bounds the groups flushed after it was created. That leaves three holes:

    • tables upgraded from alpha4 already have groups;
    • a column added after older groups were written has no zone map in those groups (columnar_vector.c:3730);
    • non-orderable types have hasMinMax = false with values present.

    A reader that trusted a partial row would under-estimate, which is the direction you said must never happen.

Proposal

Catalog. pgcolumnar.column_extent(storage_id bigint, column_index smallint, complete bool, minimum bytea, maximum bytea):

  • an index column_extent_pkey on (storage_id, column_index), not unique;
  • no GRANT, like the other storage-keyed catalogs (it holds column values);
  • min/max encoded with PgColumnarEncodeValue, as zone maps are;
  • added to PgColumnarDeleteMetadata, which also covers TRUNCATE, DROP, rewrites and the non-transactional truncate;
  • tolerated as missing, like max_upper, so a library ahead of its SQL neither breaks DROP nor trusts a missing catalog.

Writing, never updating in place. Each transaction folds its own whole-chunk zone maps per (storage, column) in backend memory, with the type's cmp proc and the column's collation. That covers every flush, serial or parallel, and every subtransaction; one rolled back can only widen, which is safe. At pre-commit, after PgColumnarFlushAllPendingWrites, it inserts one row per (storage, column) with complete = false. A transaction's groups and its extent row commit or abort together, so any snapshot sees both or neither, and concurrent writers never touch the same tuple.

Complete rows come only from recomputing. A row with complete = true is the typed fold of every whole-chunk zone map visible to one registered catalog snapshot. That snapshot is also the one used to find the extent rows to replace. The recompute takes a transaction-level advisory lock on (storage, column), refreshes the catalog snapshot after acquiring it, and then, in the same transaction:

  • inserts the complete row;
  • deletes the rows it folded (those visible to that snapshot).

It refuses to produce a complete row, leaving the old rows as they were, if any visible group lacks a zone map for the column, or has values but no min/max. Rows from transactions that commit after the snapshot are neither seen nor deleted, and they cover their own groups.

When a recompute runs:

  1. In the same transaction as anything that retires or rewrites groups (PgColumnarRetireGroup's callers: expire, compact_rewrite, online recluster, VACUUM's fully-deleted groups, compact). So a shrink is always recomputed, never guessed.
  2. At pre-commit of a writer, but only when no complete row is visible for the column, or more than K rows (say 8) exist. This one uses a conditional lock: if another session holds it, the writer just inserts its row and moves on, so loaders never wait on each other.

Reading (the planner's bound, pgcolumnar_zonemap_time_range and the #1292 phase 2 estimate). Under the reader's snapshot, if a complete row is visible for the column, the bound is the typed fold of every visible row for (storage, column). Otherwise it falls back to today's zone-map scan, which is correct and simply costlier. The fold also replaces today's double Min/Max (columnar_vector.c:2109) with the cmp proc.

Why a visible complete row makes the fold a true bound. Take a reader's snapshot S that sees the complete row R:

  • R was computed at snapshot B and committed before S.
  • Groups visible in S were either visible at B, and so are folded into R, or committed between B and S. In the second case the writing transaction's own row committed with them and is visible in S.
  • A retire is in the same transaction as its recompute, so S sees both or neither.
  • An older snapshot that doesn't see a recompute's deletes still sees the rows that recompute replaced.

Arms (both twins, a new suite column_extent)

  • The fold equals the zone-map fold: on a fresh table, for timestamptz, int, negative numeric (where byte order and value order disagree), date, and text under two collations.
  • Planning buffers stay flat from 10 to 200 groups, with the extent present; without it they grow (the instrument, not a premise).
  • Your arm: after expire retires the groups holding the minimum, and after compact_rewrite, the extent equals the recomputed zone-map fold. Not narrower, and not left wide.
  • Concurrency: two sessions inserting concurrently, both committed, then the fold covers both. An aborted writer leaves no row. A rolled-back savepoint may leave the extent wider than the fold, never narrower (asserted as a true bound, not as equality).
  • Completeness:
    • ADD COLUMN after older groups gives no complete row, and the estimate (via the fallback) is still right;
    • a non-orderable type never gets a complete row;
    • a table written before the catalog existed (upgrade from alpha4) gives no complete row until a recompute.
  • Housekeeping:
    • row count stays ≤ K + 1 per column after 50 small committing transactions;
    • TRUNCATE (both kinds) and DROP TABLE remove the rows;
    • PUBLIC can't read the catalog.

Things worth attacking

  • K, and whether the writer-side recompute should exist at all, rather than relying only on maintenance and an explicit function.
  • Pre-commit catalog writes during PREPARE TRANSACTION: is XACT_EVENT_PRE_PREPARE safe for this?
  • Projections: should they get extents? I'd say no: only the base storage feeds the estimate, so writing them costs without paying.

If this holds up, I'll write the suite red first, on a branch from main.

@OffgridwithJD

Copy link
Copy Markdown
Collaborator

One addition to the design above. The arms, and anyone checking an estimate, need to read the extent without superuser. So I'd add pgcolumnar.column_extent(rel regclass, col name) RETURNS TABLE (complete bool, minimum text, maximum text):

  • it returns the fold the planner would use, with values decoded and printed by the column type's output function;
  • it is SECURITY DEFINER and calls require_caller_select(rel), like rollup_source_groups, because the values are column data;
  • it returns zero rows when no complete row is visible, meaning the planner falls back to the zone maps.

The oracle in the arms is the data itself, min() and max() over the table. Every fixture that compares for equality first retires or rewrites its deleted rows, so the zone-map fold and the data agree; the savepoint arm asserts containment instead.

I'm starting the suite red on a local branch while you look at this. Nothing will be pushed before you've had a go at the design.

@OffgridwithJD

Copy link
Copy Markdown
Collaborator

Option 5 is built and green locally, on feat/1304-column-extent from 024e737. I'm still holding the push until you've looked at the design above, as I said. Here is what building it changed or settled, so you can review that rather than what I guessed beforehand.

Changes from the design

  • Concurrency: ordinary writers into one table never overlap. The storage-row advisory lock (class 102) serializes them from their first flush to their commit. The writers that do flush into one storage at once are parallel_copy's loaders, each through PREPARE, so the concurrency arm uses parallel_copy with 3–4 workers.
  • Two-phase commit: extent rows are written at XACT_EVENT_PRE_PREPARE, after an early flush there. The PREPARE arm's existing flush then finds nothing. An arm covers PREPARE TRANSACTION followed by COMMIT PREPARED.
  • Merge: when more than 8 rows exist and a complete one is visible, the writer folds the rows themselves. It never reads zone maps for this, so frequent small writers on a big table don't pay a full recompute every 8 commits. A full recompute from zone maps happens only when no complete row is visible, or in a transaction that retired groups.
  • Recompute on retire: PgColumnarRetireGroup marks the storage, and pre-commit recomputes every column of it under a blocking lock. That covers expire, compact_rewrite, VACUUM's fully-deleted groups, compact and the online reclusters. Projection storages are skipped.

Found while building it

  • Pre-commit has no active snapshot, so catalog writes there TRAPped on an assert build (HaveRegisteredOrActiveSnapshot). The extent code now pushes one, as the flush does.
  • My existence check (skip a storage whose row is gone, for example a table created in a rolled-back savepoint) first ran without a CommandCounterIncrement, so it couldn't see a storage row from earlier in the same transaction. Every table created and written in one transaction got no extent. An arm (ty) now covers that.
  • native_groupagg's one-scan arm now measures the fallback. It deletes the fixture's extent rows first, because with a complete extent the plan scans zone_map 0 times. Its pytest twin had to measure on a fresh connection: pg_stat_get_xact_numscans reports a backend's pending stats, which are flushed about once a second rather than at transaction end, so the INSERT's own recompute reads at commit were being counted.
  • COMMIT PREPARED in the preparing session crashes the backend after a pgcolumnar write #1323 (filed): on main, COMMIT PREPARED in the session that prepared a pgcolumnar write segfaults. The write states outlive PREPARE. That's separate from this work; the gdb backtrace is on the issue.

Evidence

  • column_extent.sh and test_column_extent.py are each 33/33 on PG15–19.
  • 19 mutations. 18 are red on their arms. The survivor is equivalent: the existence check on the catalog snapshot instead of its own, after the CommandCounterIncrement that refreshes both.
  • Ledger: 33 rows, 27 dated, 0 dated premises, and 6 never (2 premises plus 4 arms held by construction). The census goes 1698 → 1704, and gate rc=0.
  • Full PG17 matrix, before the last small commits: the only failures were approx_aggregates, because tdigest isn't installed on this box, plus the two that the later commits fixed: native_groupagg's arm and the suite's missing executable bit. Afterwards, on PG17: native_upgrade_converge 21/21, harness_selftest 1223, entry_point_privilege 31/31, native_groupagg 81 + 1 skip. The guard half passes 415, and docs_style passes.

Say the word and I'll open the PR. Or tell me what to change first.

@OffgridwithJD

Copy link
Copy Markdown
Collaborator

@jdatcmd An update on option 5 for when you review the design. It is still local and nothing is pushed.

A soundness gap I found in my own build, now fixed locally. A transaction's extent row is written at pre-commit, but its groups are visible to it as soon as they flush. So a plan made inside that transaction bounded the column by the committed rows alone: 35 estimated groups against 5000 from the zone maps. That is an under-estimate, the direction your gate must never take. The fix: the bound also folds the transaction's own pending extent, which is in memory. Both twins were seen red first. After the fix, column_extent.sh gives 36/36 and its twin 7/7 on PG15–19.

#1310 is built on top of it, also local. This is your plan there: a no-op expire returns 0 from the extent when its minimum is not below the cutoff. A 50-group no-op went from 50 zone-map scans to 0. Four checks guard it:

  • the same groups retire with or without the extent;
  • aged rows written in expire's own transaction are still retired;
  • a column left with only NULLs needs no reads.

Each was reddened by its own mutant, including one that removes the fix above. That makes the fix load-bearing for expire as well as for the planner.

Two questions for #1310's PR when it goes up:

@jdatcmd

jdatcmd commented Oct 13, 2026

Copy link
Copy Markdown
Collaborator Author

Thanks; option 5 sounds right. The in-transaction under-estimate is exactly the case a gate must never see, and red-first in both twins is the proof I'd want.

On #1310: you've built it, so please open that PR yourself, and I'll review it. My answers to your two questions:

  1. Seed ttl_expire into the ledger: yes. It's the suite for the one function that deletes rows, and it should be covered. suites_not_covered 247 → 246 is a gain.
  2. The premise: rename it. If your max-for-min mutant reddens premise: expire retired the three aged groups, then by test: 87 arms have survived every mutation aimed at their neighbours: vacuous, or named for something they do not measure #1236 it's an arm, and I'd rather it say so. That applies in both twins; the pytest side has the same name in test_expire_records_what_it_retires_and_nothing_else's neighbour. Drop the prefix and date it from your run.

When option 5 goes up, I'll look hardest at extent soundness across expire, compact_rewrite and vacuum: a stale extent that's too wide is safe; one that's too narrow isn't.

OffgridwithJD pushed a commit to OffgridwithJD/pgcolumnar that referenced this pull request Oct 13, 2026
…-map probe per group (commandprompt#1310)

When the retention column's extent puts its minimum at or past the cutoff, no
group's maximum is below it, so nothing can qualify. Built on commandprompt#1304 option 5,
whose bound includes the transaction's own flushed groups. ttl_expire.sh
65/65 on PG17.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
OffgridwithJD pushed a commit to OffgridwithJD/pgcolumnar that referenced this pull request Oct 13, 2026
…commandprompt#1236)

A mutation of expire's early exit (the extent's maximum compared instead of
its minimum) reddens it, so it measures the code under test. Renamed in both
twins, at jdatcmd's request on commandprompt#1304.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
OffgridwithJD pushed a commit to OffgridwithJD/pgcolumnar that referenced this pull request Oct 13, 2026
…ions (commandprompt#1310)

ttl_expire.sh 67/67 and test_ttl_expire.py 9/9 on each of PG15-19. Seeded at
jdatcmd's request on commandprompt#1304; suites_not_covered 247 -> 246. Ledger 2162 / 1756.
cluster_tests 518 -> 519 by collection. TESTS.md and CHANGELOG.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
jdatcmd added a commit that referenced this pull request Oct 13, 2026
Against the design on #1304. The oracle is the data's own min() and max();
equality only after deleted rows are retired or rewritten, containment for
the one case that may legitimately be wider (a rolled-back savepoint that
flushed a group).

Red on PG17 (main at 024e737): 6 passed + 25 failed. The function does not
exist (42883) and planning still scans zone_map once. The six that pass hold
on main by design: the fixture premise, the fallback and its estimate, two
concurrent writers committing, expire and compact_rewrite doing their jobs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
jdatcmd added a commit that referenced this pull request Oct 13, 2026
…1304 option 5)

pgcolumnar.column_extent holds rows of (storage, column, complete, minimum,
maximum), inserted and deleted, never updated in place:

- each transaction folds the whole-chunk zone maps it flushes (the type's
  comparison, the column's collation) and inserts one row at pre-commit, or
  pre-prepare; its groups and its row commit together, and two writers never
  touch one tuple;
- a complete row comes only from a recompute over every zone map one snapshot
  sees, which replaces the rows that snapshot sees; it refuses when a group has
  no zone map for the column or values without an ordering;
- PgColumnarRetireGroup marks the storage and it is recomputed before commit,
  so expire, compact_rewrite, VACUUM and the reclusters shrink the extent by
  recomputing it;
- a writer recomputes when it finds no complete row and merges the rows when
  there are more than eight (a fold of those rows only).

The planner's time range reads the extent when a complete row is visible and
otherwise the zone maps. pgcolumnar.column_extent(rel, col) shows it, to a role
that can SELECT the table.

Found while making the suite green: pre-commit has no active snapshot (an
assert-build TRAP), and the catalog snapshot can predate a storage row this
transaction inserted, which made the existence check skip every table created
and written in one transaction.

column_extent.sh 33/33 on PG17, with two arms added since the red commit
(PREPARE TRANSACTION, and a table created in a rolled-back savepoint).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
jdatcmd added a commit that referenced this pull request Oct 13, 2026
…roupagg measures the fallback (#1304 option 5)

- The concurrency arm now uses parallel_copy. Ordinary writers into one table
  are serialized by the storage-row lock (class 102) from first flush to commit,
  so the old two-writer arm never overlapped them. parallel_copy's loaders flush
  into one storage at once, each through PREPARE.
- native_groupagg's one-scan arm measures the zone-map fallback, so it removes
  the fixture's extent rows first; with a complete extent the plan scans zone_map
  not at all. The pytest twin measures on a fresh connection: the xact scan
  counters are a backend's pending stats, flushed about once a second rather than
  at transaction end, and in the fixture's connection they still held the
  INSERT's own zone_map reads (the extent's first recompute, at its commit).
- test_column_extent.py: the independent twin. Its COMMIT PREPARED runs in another
  session; in the preparing one it crashes the backend on main (to be filed).
- column_extent.sh is executable (harness_selftest).

PG17: column_extent 33/33, native_groupagg 81 + 1 skip, harness_selftest 1223.
PG16: test_column_extent.py + test_native_groupagg.py 122 + 1 unrunnable (no ICU
on this build).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
jdatcmd added a commit that referenced this pull request Oct 13, 2026
…a recompute (#1304 option 5)

Forget was redundant: pre-commit already skips any entry whose storage row is
gone, so no arm could tell it was there. The no-ordering arm could not fail: a
point column never gets a writer row, so no recompute ever ran for it, and a
complete row with NULL bounds printed as "none" anyway. It now runs VACUUM over
a fully deleted group, and both twins print a NULL bound as '-'.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
jdatcmd added a commit that referenced this pull request Oct 13, 2026
…he pair, cluster_tests 517 (#1304 option 5)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
jdatcmd added a commit that referenced this pull request Oct 13, 2026
…tions (#1304 option 5)

Clean: column_extent.sh and test_column_extent.py 33/33 on PG15-19. Each
mutation log merged one at a time with --mutation and --target. A nineteenth
(the existence check on the catalog snapshot) survived and is equivalent.
Census 1698 -> 1704; rows 2092; the gate agrees.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
jdatcmd added a commit that referenced this pull request Oct 13, 2026
… reads, red (#1304 option 5)

Its extent row is written at pre-commit, but its groups are visible to it
as soon as they flush. Seen red on PG16 in both twins: column_extent reads
'no', and the in-transaction estimate is 35 rows (61 in the twin) against
the zone maps' 5000 (4500), an under-estimate.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
jdatcmd added a commit that referenced this pull request Oct 13, 2026
 option 5)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
jdatcmd added a commit that referenced this pull request Oct 13, 2026
…'s docs (#1304 option 5)

column_extent.sh 36/36 and test_column_extent.py 7/7 on each of PG15-19. The
two arms are dated by the run before the fix; the instrument premise arrives
never. Ledger 2095 / 1705. cluster_tests 517 -> 518 by collection.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
jdatcmd added a commit that referenced this pull request Oct 13, 2026
feat: a per-column extent the planner reads in one lookup, recomputed whenever groups go (#1304 option 5)
OffgridwithJD pushed a commit to OffgridwithJD/pgcolumnar that referenced this pull request Oct 13, 2026
…-map probe per group (commandprompt#1310)

When the retention column's extent puts its minimum at or past the cutoff, no
group's maximum is below it, so nothing can qualify. Built on commandprompt#1304 option 5,
whose bound includes the transaction's own flushed groups. ttl_expire.sh
65/65 on PG17.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
OffgridwithJD pushed a commit to OffgridwithJD/pgcolumnar that referenced this pull request Oct 13, 2026
…commandprompt#1236)

A mutation of expire's early exit (the extent's maximum compared instead of
its minimum) reddens it, so it measures the code under test. Renamed in both
twins, at jdatcmd's request on commandprompt#1304.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
OffgridwithJD pushed a commit to OffgridwithJD/pgcolumnar that referenced this pull request Oct 13, 2026
…ions (commandprompt#1310)

ttl_expire.sh 67/67 and test_ttl_expire.py 9/9 on each of PG15-19. Seeded at
jdatcmd's request on commandprompt#1304; suites_not_covered 247 -> 246. Ledger 2162 / 1756.
cluster_tests 518 -> 519 by collection. TESTS.md and CHANGELOG.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
jdatcmd added a commit that referenced this pull request Oct 13, 2026
…mate (#1292)

The owner's decision on #1292: pgcolumnar.enable_group_vectorization is on by
default, and the planner takes the grouped path only when the estimated group
count is at most a tenth of pgcolumnar.groupagg_max_groups. Over the cap the
node errors, since its plan is fixed by then, so an ungated default would
turn a high-cardinality GROUP BY from a working query into an error. The
tenth is a margin for an estimate up to 10x low; #369's bound and #1304's
time buckets keep bucket estimates within a small factor. The runtime cap
stays as the backstop.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
jdatcmd added a commit that referenced this pull request Oct 13, 2026
…timate (#1330 review)

The planner takes the grouped path only when an upper bound on the group count
is within pgcolumnar.groupagg_max_groups: the relation's live row count (from
the row-group catalog and delete vectors, before any WHERE), or, where every
key is a time bucket, the product of the bucket bounds (#369, #1304) with one
group per key for NULL. An estimate could not be the gate: a skewed key fools
ANALYZE's own estimator by 68x with fresh statistics. With a bound the default
never errors; the cap is reached only if it falls, or the table grows past
it, after planning. Each key's time bound is read once per plan and shared by
the estimate and the gate, so planning still scans zone_map once.

PG17: native_groupagg 89/89 (+1 skip), test_native_groupagg.py green.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants