Skip to content

opt: reclaim the row-group list a columnar rescan rebuilds (#734) - #737

Merged
jdatcmd merged 1 commit into
mainfrom
opt/734-plain-scan-rescan
Aug 25, 2026
Merged

opt: reclaim the row-group list a columnar rescan rebuilds (#734)#737
jdatcmd merged 1 commit into
mainfrom
opt/734-plain-scan-rescan

Conversation

@jdatcmd

@jdatcmd jdatcmd commented Aug 25, 2026

Copy link
Copy Markdown
Collaborator

Closes #734.

A rescan reuses the read state rather than closing and re-opening it
(PgColumnarReScanCustomScanPgColumnarRescanRead), and that function sets
rowGroupList to NIL without freeing it. The next start rebuilds the list and
a NativeRowGroupMetadata per group into the read state's own context, so a
LATERAL or parameterized scan accumulated one list per outer row for the life of
the query.

The mechanism was pinned first, not guessed

A memory-context dump named it: columnar read, 8,388,608 bytes over 40,000
rescans. readContext itself cannot simply be reset — the read state and its
missing values live there too — so the list gets a child context the rescan
resets.

(I filed this issue deliberately without a hypothesis, having been wrong twice
guessing at this class on #727 and #731.)

Measured against a heap floor, not against zero

arm before after
plain columnar scan 198 B/rescan 33 B
heap table, identical data 33 B/rescan 33 B

Two proofs

Removal. Reverting gives 190 B against a 35 B floor — 155 B excess, arm red
— while the vectorized-aggregate arm beside it stays at 1 B, so the two arms are
independent.

Lifetime. This frees metadata structs, so a reset in the wrong place is a
use-after-free. Injecting one (reset while the list is live) is loudly fatal:
ERROR: invalid memory alloc request size 7741792704550691685 from a freed
struct, with every premise collapsing. Per asan-cannot-see-context-reset ASAN
cannot see a MemoryContextReset use-after-free at all, so validating that the
suite can see it is the check that matters here.

Gated on the full 209-suite PG17 matrix

Not a chosen neighbourhood — this is in the core reader and every read path uses
it, so picking neighbours would have been a guess about blast radius.

One suite failed, and it is not this change. native_upgrade_converge fails
identically on clean main, CI passes it on the same commit, and the cause is
two stale extension base scripts in the local sharedir that the repo no longer
ships (pgcolumnar--1.0-alpha.sql dated Aug 16, pgcolumnar--1.0-dev.sql dated
Aug 4), left behind by an August run of extension_upgrade.sh. The stale alpha
script already creates the Iceberg FDW; the current upgrade script then creates
it again.

They were present on every major, so the five-major release gate would have
failed on all of them for a reason that is not the code. Moved aside (not
deleted — the suite regenerates them when it can run); the suite then passes
5 of 5.

🤖 Generated with Claude Code

https://claude.ai/code/session_01G8jGnYAbTgiAcoUqn7E9EN

A rescan REUSES the read state rather than closing and re-opening it
(PgColumnarReScanCustomScan calls PgColumnarRescanRead), and that function sets
rowGroupList to NIL without freeing it. The next start rebuilds the list and a
NativeRowGroupMetadata per group into the read state's own context, so a LATERAL
or parameterized scan accumulated one list per outer row for the life of the
query.

The mechanism was pinned before the fix, not guessed. A memory-context dump
named the context -- "columnar read", 8,388,608 bytes over 40,000 rescans -- and
the code explained it. readContext itself cannot simply be reset, since the read
state and its missing values live there too, so the list gets a child context
the rescan resets.

Measured against the same query over a heap table with identical data, so the
figure is what pgcolumnar adds rather than what re-executing any node costs:
198 bytes per rescan against a 33 byte heap floor before, 33 against 33 after.

Two proofs. Removing the fix returns 190 B against a 35 B floor, 155 B excess,
and reddens the arm while the vectorized-aggregate arm beside it stays at 1 B,
so the two are independent. And because this frees metadata structs, the
lifetime error matters: resetting the context while the list is live is loudly
fatal, "invalid memory alloc request size 7741792704550691685" from a freed
struct, with every premise collapsing. ASAN cannot see a MemoryContextReset
use-after-free at all, so that check is what says the suite covers this.

Gated on the full 209-suite PG17 matrix rather than a chosen neighbourhood,
because this is in the core reader and every read path uses it. One suite
failed, native_upgrade_converge, and it is NOT this change: it fails identically
on clean main, CI passes it on the same commit, and the cause is two stale
extension base scripts in the local sharedir that the repo no longer ships,
left behind by an August run of extension_upgrade.sh. Moved aside on every
major; the suite then passes 5 of 5.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G8jGnYAbTgiAcoUqn7E9EN

@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.

APPROVE — reviewed as ChronicallyJD. Verified on the merged tree (current main 5dea386 + this PR), pg17a assert, in my audit container; core-reader change, so I ran the sanitizer too.

The fix

Giving the row-group list its own groupListContext child of readContext and resetting it in PgColumnarRescanRead is the right shape — readContext can't be reset (the read state and its missing values live there), and clearing rowGroupList to NIL alone never reclaimed the previous list. I checked the reset is safe rather than taking the comment's word: after MemoryContextReset(groupListContext) every pointer into it is cleared in the same block — rowGroupList = NIL, nativeGroup = NULL, and the native* cursors. The only cached element pointer is nativeGroup (via list_nth at the group boundary), and it's nulled. The other PgColumnarReadRowGroupList call sites build their lists in their own contexts, not this one, so nothing else dangles across the reset.

Removal proof — reproduced

Reverting the reader change reddens the #734 arm: plain columnar scan 190 B/rescan vs 33 B heap floor, excess 157 B (gate <100). With the fix, 33 vs 35, excess 0. The arm sees the fix's absence, and 157 B lands where the issue's 165 B did.

Lifetime — the reset is the risk, so I attacked it

A MemoryContextReset is invisible to ASAN when the freed blocks aren't returned to the C allocator, so the assert build carries this one. Injecting the reset while the list is still live is loudly fatal: ERROR: invalid memory alloc request size 7741792704550691685 (a freed struct read as a length), connection lost, premises collapsing — the same value you saw, reproduced independently. So the suite genuinely sees this class here; the shipped placement is safe because every pointer into the context is cleared before the next read.

Coexistence + surrounding path

This is the third rescan-memory fix in the same fixture. On the merged tree all of them hold together: #734 (plain scan) excess 0, #727 (vector agg) excess 0, #717 (scan keys) excess 0, row-path pruning still does its work. And the reader change doesn't disturb the pushdown rescans — native_saop_pushdown 30/30 and native_param_pushdown 5/5 green.

Sanitizer

ASAN+UBSAN clean over the rescan and read path — vector_agg_rescan_memory, native_saop_pushdown, native_roundtrip all pass under pg18_san, 0 checks vacated. Complementary to the assert-build inject above, since a context reset can be partly invisible to ASAN.

Docs

CHANGELOG is honest — the reuse-not-reopen mechanism, the one-list-per-outer-row accumulation, and the differential-vs-heap figure (198→33 against a 33 floor). No over-claim.

Mechanism pinned before the fix, proven by removal, lifetime attacked on the build that can see it. Clean.

@jdatcmd
jdatcmd merged commit 106e32e into main Aug 25, 2026
12 checks passed
@jdatcmd
jdatcmd deleted the opt/734-plain-scan-rescan branch August 25, 2026 11:52
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.

opt: the ordinary columnar scan grows ~165 bytes of query memory per rescan, above core's own baseline

2 participants