fix: coalesce adjacent column reads on index fetch, with the validity copy bounded (#1077, rebased + ASAN fix) - #1093
Conversation
pgcolumnar_fetch_row issued two ReadLogicalData calls per column. Sequential scan already merged touching ranges. A wide btree fetch of a small group pinned the same pages once per column. Co-authored-by: Cursor <cursoragent@cursor.com>
CI suites (PG 17) refused these checks because a PG18-only seed left majors=18. The suite was run on PGDG 15.19, 16.15, 17.11 and Ubuntu 18.6 and those logs were merged. PG19 is not installed here. Co-authored-by: Cursor <cursoragent@cursor.com>
5fe2fd9 to
f780a2a
Compare
…pt#1077 review) The coalesced fetch path copies validityBytes out of a span buffer that is only guaranteed to hold page_length bytes for the chunk being served. The test that reconciles the two ran three lines AFTER the copy, so a chunk whose catalog page_length was smaller than its validity bitmap read past the allocation. Reproduced on a build with -fsanitize=address, by poisoning pgcolumnar.column_chunk.page_length on the last chunk by page_offset and issuing a plain index-scan SELECT: AddressSanitizer: heap-buffer-overflow READ of size 625, 0 bytes after a 2640-byte region pgcolumnar_fetch_coalesce_read (the memcpy) pgcolumnar_fetch_row printtup The backend died and the cluster entered crash recovery. Main cannot have this shape: its non-coalesced fill reads straight from storage into an exactly-sized destination, so there is no in-memory extent to exceed. The span buffer and the copy out of it are both introduced by this change. Hoisting the page_length >= validityBytes test above the copy closes it; an inconsistent chunk is left for the non-coalesced path, which refuses it there. THE REGRESSION ARM IS AN ORDERING PIN, NOT A BEHAVIOURAL ONE. Reading ~117 bytes past a palloc'd span reads adjacent heap and returns quietly without a sanitizer, so a behavioural arm would report PASS on the broken code. Both harnesses assert the order, each reading the source its own way: awk over line numbers in the shell suite, a regex over character offsets in the pytest twin. Neither invokes the other. Proved by MOVING the guard below the copy rather than deleting it, which leaves both statements present and reddens only the ordering arm: guard hoisted 7 passed + 0 failed guard moved 6 passed + 1 failed (the premise stays green) Ledger rows re-derived from runs on all five majors rather than by editing the majors field: 7/7 on PG15-19, 1237 rows, census 1229, gate rc=0. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012RSw4qMHS7ByE7PY8Ns4cs
f780a2a to
643b342
Compare
jdatcmd
left a comment
There was a problem hiding this comment.
Approving on the merits at 643b3424. It needs a rebase and a renumber before it can land, see the end.
The memory-safety fix is correct, and I checked the fallback rather than the diff
The bound at 4170 precedes the copy at 4181. The continue is safe: the caller sees entry->vbits[c] == NULL, allocates exactly validityBytes, and reads via PgColumnarReadLogicalData into that exactly-sized buffer. So an inconsistent chunk really is deferred to a path that cannot overread it, which is what the comment claims.
I also chased the new (uint32) (cc->pageLength - validityBytes) in the distribution loop as a possible reintroduction of #1063 and it is not one. span = end - start goes through palloc, which refuses above 1GB, so the cast cannot be reached with a value stream large enough to truncate.
The gawk failure is fixed, and CI now proves it
The previous head was red on suites (PG 17) and suites (PG 18) while the property under test held. Cause: awk -v processes escape sequences in the value, and \( is undefined, so the two implementations disagree.
gawk warning: escape sequence `\(' treated as plain `(' -> regex GROUP, never matches
gawk fatal: invalid regexp: Unmatched ( or \(: /memcpy(entry->vbits/
mawk 4150 / 4161
One pattern silently failed to match and the other killed awk. The bracket form is immune to -v escape processing and I measured it identical under both. This head is 14 of 14 green, which is the end-to-end confirmation.
The anchor fix is right, and I ran the removal proof myself
With the loop-1 deferral in, the function holds two textually identical guards, and a first-match extractor would pin the wrong one. Anchoring on cc->pageOffset < start is better than a positional rule, because it is a structural marker of the distribution loop rather than a count that goes stale.
Verified by deleting only the distribution guard and keeping the deferral:
unmutated guard=4170 copy=4181 -> check says yes
mutant guard=[] copy=4179 -> check says no
So it fails for the intended reason. The pytest twin carries the same anchor, which neither of us had checked before.
Composition with #1092, measured
Before the deferral, composing the two made #1092's own arm fail:
FAIL ... refused (XX001): got [XX000] ERROR: invalid memory alloc request size 4294971754
With the deferral, on the composed tree:
native_chunk_length_bound 6 passed + 0 failed XX001 restored
native_fetch_coalesce 7 passed + 0 failed
"a wide index fetch does not pin once per column" still PASS
That last line matters, because a guard that fixed the SQLSTATE by disabling coalescing would also have gone green.
Before merging
mergeable=CONFLICTING since #1092 landed: both claim TESTS.md section 44. This needs a rebase onto fb6ad6a, a renumber to 45, and the ledger and budget re-derived by counting against the new main rather than resolved as a conflict.
I will re-verify the rebase by per-file patch id rather than re-reading it, so the approval transfers on evidence.
…-validity-copy # Conflicts: # CHANGELOG.md # test/check_ledger.tsv # test/check_ledger_budget.txt # test/pytest/TESTS.md # test/pytest/expected_tests.txt # test/pytest/test_compare_to_bash.py
5dc4bc0 to
58e0d8b
Compare
This is @linuxhikerpm's #1077, rebased onto
cfdb393with a memory-safety fix, a regression arm in both harnesses, and its ledger re-derived. Their commits are preserved; mine is the last one. Opened from my fork because I cannot push to theirs.Rebase this onto #1092 (their #1063) before merging — see the last section.
The coalescing is legitimate and correct
I read
pgcolumnar_fetch_coalesce_readrather than skimming it. Sorting the ranges, merging any whose start falls at or before the running end, onePgColumnarReadLogicalDataper merged span, then distributingvbits/valueStreamper column — the merge is right, including the adjacent-not-just-overlapping case, which is the point. Holding the span buffers in the per-fetch context sovalueStream[c]can point into them is deliberate and correct.The defect: the validity copy was not bounded by the chunk
The containment test guarantees
[off, off+pageLength)lies insidebuf. The copy readsvalidityBytes. Those coincide only under the condition at 4146 — three lines after the copy.Reproduced on a build with
-fsanitize=address, poisoningpgcolumnar.column_chunk.page_lengthon the last chunk bypage_offsetand issuing a plain index-scanSELECT:The backend died and the cluster entered crash recovery. It reaches the user through
printtup, so it is an ordinarySELECT.This is new here. Main's non-coalesced fill reads straight from storage into an exactly-sized destination, so no in-memory extent exists to exceed. The span buffer and the copy out of it are both introduced by this change.
The fix, and why the arm is an ordering pin
Hoisting
pageLength >= validityBytesabove the copy. An inconsistent chunk is left for the non-coalesced path, which refuses it.A behavioural arm would be vacuous here: reading ~117 bytes past a palloc'd span reads adjacent heap and returns quietly without a sanitizer, so the suite would report PASS on the broken code. The sanitizer run is the behavioural proof and belongs to the nightly ASAN job. What a suite can assert deterministically is the property that was wrong — the order.
Both harnesses assert it, each reading the source its own way (awk over line numbers; a regex over character offsets). Neither invokes the other.
Proved by moving the guard below the copy rather than deleting it, so both statements stay present and only the ordering arm reddens:
Verified
Ledger rows re-derived from those runs, not by editing field 4.
Merge order
Takes TESTS.md section 44, correct while main is at 43. All five of these take 44; the numbering is gated, so the second to land renumbers and re-derives its census — the conflict offers two numbers and neither is right (#996).
Land #1092 first. With this fix alone, a skipped chunk falls through to the
uint32cast still atcolumnar_reader.c:4479, and the user seesinvalid memory alloc request size 4294966679(= 2^32 − 617, from8 − 625) instead of the typedXX001. Measured:invalid memory alloc request size 4294966679XX001— inferred, not measuredOriginal: #1077. Author: @linuxhikerpm.