fix: free the block codec's buffer, which both paths abandoned (#1075) - #1083
Conversation
`flush_one_column` compresses the whole encoded region of a column chunk and then
leaves the codec's buffer allocated, on BOTH paths.
When the codec declines, `PgColumnarCompressValueStream` returns a palloc'd COPY
of the raw bytes rather than NULL -- its documented contract, so every caller owns
a buffer with the same ownership semantics. This caller never reads that copy:
`finalData` still points at `encoded->data`. When the codec succeeds,
`appendBinaryStringInfo` has already copied the bytes into `chunk`, so the buffer
is dead from that line on.
It is bounded rather than a leak, because `flushContext` is deleted per row group.
What it costs is peak allocation: it roughly doubles what the flush holds for the
encoded region while every other column of the row group is still flushing.
Freed after the append rather than inside either branch, so the success path is
covered too. A free placed only in the declined arm is the version that reads as
complete and is not.
MEASURED ON TWO FIXTURES, AND THE FIRST FOUND NOTHING. Peak RSS of the loading
backend (`VmHWM`), lz4, byte-identical input every run:
200,000 rows, default stripe baseline 117,178 kB patched 118,002 kB
600,000 rows, ONE stripe baseline 326,584 kB patched 304,979 kB
The first is +0.70%, the WRONG DIRECTION, and it is reported because it is the
honest half: at that scale the encoded region is a few MB against a 117 MB process
and the effect is swamped. The second saves 21.1 MiB, 6.6% of peak, against
repetition spreads of 0.14% and 0.27%.
So the saving is proportional to the row group's encoded size, and on a
default-sized stripe of narrow data it is not observable. My prior that it would
show on the first fixture was wrong, and scaling until the candidate behaviours
diverge is what settled it.
Stored bytes do not move, which is the requirement for a memory fix: 32,055,856
bytes and fingerprint `a0959193` identical across every run of both builds, and
10,671,067 / `d0b289c5` on the smaller one.
NO NEW TEST ARM, deliberately. What changed is peak allocation, and there is no
stable way to assert that in CI here: a probe of `pg_log_backend_memory_contexts`
would have to land mid-flush. The correctness requirement is that output does not
move, which the existing content suites cover and which was verified by
measurement. An arm grepping the source for `pfree(codecBuf)` would be the exact
shape repaired in `8e88f42` and `f115d0b`.
Write-path suites on PG18 non-assert, unchanged:
encode_invariants 13 passed write_fsst_compressed 9 passed
native_encoding 46 passed fsst_margin 13 passed
fsst_verdict_cache 16 passed encode_effort 10 passed
NOT DOING the incompressibility estimator that #1075 also proposes. It can
silently lose real compression, and refusing to change storage behaviour on an
unproven premise is #890's own lesson applied to ourselves.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NhwXKAgSmYDUjteWkfajHK
|
Verifying the one thing this change rests on, rather than leaving it as an appendBinaryStringInfo(StringInfo str, const void *data, int datalen)
{
Assert(str != NULL);
enlargeStringInfo(str, datalen);
memcpy(str->data + str->len, data, datalen);
str->len += datalen;
str->data[str->len] = '\0';
}
The four paths through the block, all of them safe:
The fourth is the one worth naming because it is the only path where the append This is the question I would most want a second reader on, so I would rather show |
…ntees TWO FIXES, one of them a hole in this PR that review did not look for. THE CHANGELOG ENTRY WAS NEVER COMMITTED. `f115d0b` contains only `test/native_fetch_projection.sh`. The entry was written, sat unstaged, and was lost: `git reset --soft` leaves the index alone, `git commit` commits the index, and the working-tree edit never entered it. I checked with `git diff --stat origin/main` AFTER committing, which compares the WORKING TREE to main rather than the commit to main, so it showed both files and read as confirmation. That check cannot see this class of mistake at all. The one that can is `git show <sha> --name-only`, which reads the commit. Checked the sibling branches by the same means: #1082 and #1083 both carry their CHANGELOG entries. Only this one was affected. AND THE THIRD PREMISE'S COMMENT OVERSTATED ITS JOB. It read "the whole arm rests on it". It does not. A broken prefix relationship cannot pass silently, because the other two arms already contradict each other under it: if `Cols(` matched the wide pattern then every narrow call would be counted twice, so `full >= cols`, and `cols >= 1` with `full == 0` is a contradiction. cols=1, prefix intact -> full=0 arm PASS cols=1, prefix broken -> full=1 arm RED cols=3, prefix broken -> full=3 arm RED The premise documents the assumption and names what a future rename would break. It is not the guarantee. Kept, with the comment now saying which it is. Correction from @OffgridwithJD's review. NO CHECK NAME MOVES, so no ledger key moves: the 17 names are identical to `f115d0b` by sorted diff. Suite re-run on PG17 non-assert after the change, 17 passed + 0 failed + 0 unrunnable + 0 skipped = 17. Merged `origin/main` to pick up #1080, and verified the roadmap amendment survives in the merged tree rather than assuming it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NhwXKAgSmYDUjteWkfajHK
OffgridwithJD
left a comment
There was a problem hiding this comment.
APPROVE at 5ce1121. Straight answers to both questions, neither by re-reading your reasoning.
1. Is pfree after the append safe on the success path? Yes
From PostgreSQL's own source, src/common/stringinfo.c:
appendBinaryStringInfo(StringInfo str, const void *data, int datalen)
{
enlargeStringInfo(str, datalen);
memcpy(str->data + str->len, data, datalen); /* copies */
str->len += datalen;
...
}It memcpys. chunk never aliases finalData, so the buffer is dead from that line and freeing after it is correct.
Three more things I checked rather than assumed:
- No early exit between the allocation and the free. The only
returnin that range isreturn result;after thepfree. - The decline path cannot double-free or use-after-free:
finalDatastill points atencoded->data, never atcodecBuf. finalLen == 0skips the append but not the free, which is right — nothing read the buffer, andcodecBufbeing NULL-initialised means theif (codecBuf != NULL)guard covers the case where the whole block was skipped.
Declaring codecBuf at function scope and initialising it to NULL before the if is the detail that makes the guard total rather than approximately total.
2. Is "no arm" the right call? Yes, and the precedent is the argument
I could not find a seam either, and I went looking specifically:
pg_backend_memory_contextsreflects the current state; the flush context is deleted per row group, so nothing queryable survives to observe.- ASAN cannot see it. It is a context-scoped allocation reclaimed by context delete, which is the exact blindness this project already has recorded for a
MemoryContextResetlifetime bug. - A source-grep arm is the shape we spent today repairing three times over, and you were right to refuse it.
- An arm on peak RSS is machine-dependent, and your own numbers are the argument against it: +0.70% on the small fixture, which is the wrong direction and inside nothing.
What is left is instrumentation whose only consumer is a test, and this project has declined that before — an internal seam for analyze() was rejected in favour of building on the public function. Adding a memory-accounting hook to the writer so a test can read it would be the same trade.
So "there is not one" is defensible here, not just "I could not find one". The distinction you drew is the right one and I am answering the stronger version of it.
What I verified instead, since the benefit is not assertable
A write-path change's real requirement is that it changes nothing about what lands on disk. Independent fixture, 200,000 rows of md5() text, three codecs, both builds:
codec stored_bytes content_md5
BASELINE none 7,255,675 2b0723e06e68
BASELINE lz4 7,255,494 2b0723e06e68
BASELINE zstd 7,426,159 2b0723e06e68
PATCHED (#1083) none 7,255,675 2b0723e06e68
PATCHED (#1083) lz4 7,255,494 2b0723e06e68
PATCHED (#1083) zstd 7,426,159 2b0723e06e68
Byte-identical storage and byte-identical content on every codec. That is the claim the PR needs and it now rests on a fixture neither of us wrote into the suites.
Incidentally it reproduces #1074 on the way past: zstd stores 7,426,159 against none's 7,255,675 on this shape, +2.35%.
The +0.70% row is why I trust the rest
Shipping the measurement that went the wrong direction, and saying the small fixture is the more useful number because it says when the fix does not matter, is what makes the 6.6% credible. A PR that only carried the favourable number would have been weaker evidence for the same change.
On the push near-miss
git diff --name-only against main before every push is the right rule, and the tell being the same both times — the file list not matching the commit message — is worth more than the rule. Worth pairing with the other half you found on #1079: after a commit, git show <sha> --name-only answers the question git diff --stat origin/main only appears to.
The only conflict is CHANGELOG.md, and both sides add entries to `### Fixed`, so both are kept. #1080, #1081 and #1083 landed while this was open. Verified by count rather than by reading the diff: markers left 0 entries present exactly once #1074/#1076, #1075, #1077, #1080, #1081 bodiless headings in [Unreleased] 0 Nothing else moved. Per-file patch md5 of my seven files, merged result against the pre-merge branch, added and removed lines only: docs/administration.md 6a456b0edcac same docs/best-practices.md 0c84c03e77bd same docs/configuration.md 68b2705ebe8a same test/fsst_margin.sh 978e4e448429 same test/pytest/TESTS.md 787346d68405 same test/pytest/expected_tests.txt d1807c9dfd54 same test/pytest/test_compression_reaches_the_cascade.py 025da1ba7426 same main moved none of `expected_tests.txt`, `check_ledger.tsv` or `check_ledger_budget.txt` -- checked by md5 against 8e88f42 rather than assumed from the fact that the merges were docs, shell suites and one `src/` file. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NhwXKAgSmYDUjteWkfajHK
CHANGELOG.md only. #1081 and #1083 landed while this was open and both add to `### Fixed`, as this does, so all three entries are kept. Verified by count rather than by reading the diff: conflict markers left 0 each entry present exactly once #1077 sweep, #1075, #1080, #1081, and #1078's, which was already there bodiless headings in [Unreleased] 0 The suite file is untouched by the merge: its patch md5 against main is unchanged from before it, and the 17 check names are identical by sorted diff, so no ledger key moves. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NhwXKAgSmYDUjteWkfajHK
flush_one_columncompresses the whole encoded region of a column chunk and thenleaves the codec's buffer allocated, on both paths.
When the codec declines,
PgColumnarCompressValueStreamreturns a palloc'dcopy of the raw bytes rather than NULL — its documented contract, so every caller
owns a buffer with the same ownership semantics. This caller never reads that copy:
finalDatastill points atencoded->data. When the codec succeeds,appendBinaryStringInfohas already copied the bytes intochunk.It is bounded rather than a leak —
flushContextis deleted per row group.What it costs is peak allocation: it roughly doubles what the flush holds for the
encoded region while every other column is still flushing.
Freed after the append rather than inside either branch, so the success path is
covered too. A free placed only in the declined arm is the version that reads as
complete and is not.
Measured on two fixtures, and the first one found nothing
Peak RSS of the loading backend (
VmHWM), lz4, byte-identical input every run:The first is +0.70% — the wrong direction. It is here because it is the honest
half: at that scale the encoded region is a few MB against a 117 MB process and the
effect is swamped. The second saves 21.1 MiB, 6.6% of peak, against repetition
spreads of 0.14% and 0.27%.
So the saving is proportional to the row group's encoded size, and on a
default-sized stripe of narrow data it is not observable at all. My prior that it
would show on the first fixture was wrong; scaling until the candidate behaviours
diverge is what settled it.
Stored bytes do not move, which is the requirement for a memory fix:
32,055,856 bytes / fingerprint
a0959193identical across every run of bothbuilds, and 10,671,067 /
d0b289c5on the smaller one.No new test arm, deliberately
What changed is peak allocation, and there is no stable way to assert that in CI
here — a probe of
pg_log_backend_memory_contextswould have to land mid-flush.The correctness requirement is that output does not move, which the existing
content suites cover and which is verified by measurement above.
An arm grepping the source for
pfree(codecBuf)would be the exact shape repairedin
8e88f42andf115d0bearlier today, and is not worth having. Say if youdisagree — I would rather be argued into an arm than ship a decorative one.
Write-path suites, PG18 non-assert
Not doing
The incompressibility estimator #1075 also proposes. It can silently lose real
compression, and refusing to change storage behaviour on an unproven premise is
#890's own lesson applied to ourselves. The issue stays open for it.
🤖 Generated with Claude Code
https://claude.ai/code/session_01NhwXKAgSmYDUjteWkfajHK