Skip the COPY file header when scanning for row boundaries#532
Merged
staticlibs merged 1 commit intoJul 25, 2026
Merged
Conversation
PostgresBinaryFileReader::FindLastCompleteRow started scanning at byte zero of the first buffer, so it read the 19 byte binary COPY file header as if it were a tuple: 'PG' became a field count of 20551 and 'COPY' became a field length of 1129270361. No row could ever be completed, the scan returned 0, and the reader threw Postgres binary file contains a row larger than the read buffer for any file that did not fit in a single buffer. With the default 32MB buffer that rejected every postgres binary file larger than 32MB, regardless of how small its rows were. Skip the header while scanning for row boundaries on the first fill, but keep it in the buffer so CheckHeader can still validate it. When no complete row fits alongside the header, hand the parser just the header and carry the partial row over to the next fill, where the whole buffer is available for it. Otherwise a row larger than buffer_size - 19 but no larger than buffer_size would still be rejected even though it fits. CheckHeader's bound becomes '>' so that a buffer holding exactly the header is accepted. Files that fit in one buffer were unaffected, which is why the existing tests - all of which use tiny fixtures - did not catch this.
marknefedov
force-pushed
the
fix/binary-copy-header-boundary-scan
branch
from
July 24, 2026 21:39
a0f93a5 to
a404c80
Compare
marknefedov
marked this pull request as ready for review
July 24, 2026 21:45
staticlibs
approved these changes
Jul 25, 2026
staticlibs
left a comment
Member
There was a problem hiding this comment.
Thanks for the PR! Looks good to me.
staticlibs
pushed a commit
that referenced
this pull request
Jul 25, 2026
This is a backport of the PR #532 to `v1.5-variegata` stable branch. PostgresBinaryFileReader::FindLastCompleteRow started scanning at byte zero of the first buffer, so it read the 19 byte binary COPY file header as if it were a tuple: 'PG' became a field count of 20551 and 'COPY' became a field length of 1129270361. No row could ever be completed, the scan returned 0, and the reader threw Postgres binary file contains a row larger than the read buffer for any file that did not fit in a single buffer. With the default 32MB buffer that rejected every postgres binary file larger than 32MB, regardless of how small its rows were. Skip the header while scanning for row boundaries on the first fill, but keep it in the buffer so CheckHeader can still validate it. When no complete row fits alongside the header, hand the parser just the header and carry the partial row over to the next fill, where the whole buffer is available for it. Otherwise a row larger than buffer_size - 19 but no larger than buffer_size would still be rejected even though it fits. CheckHeader's bound becomes '>' so that a buffer holding exactly the header is accepted. Files that fit in one buffer were unaffected, which is why the existing tests - all of which use tiny fixtures - did not catch this.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
PostgresBinaryFileReader::FindLastCompleteRowstarts scanning at byte zero of the first buffer, so it reads the 19 byte binary COPY file header as if it were a tuple:'P','G'→ field count20551'C','O','P','Y'→ field length1129270361That length exceeds any buffer, so no row is ever completed, the scan returns 0, and
FillBufferthrows:This fires for any file that does not fit in a single buffer. With the default
DEFAULT_BUFFER_SIZEof 32 MiB, every postgres binary file larger than 32 MiB is rejected, no matter how small its rows are.Files that fit in one buffer are unaffected, because
file_offset >= file_sizemakesFillBufferfall back tovalid = totaland the misparse is never observed. Every existing fixture intest/sql/misc/postgres_binary_read_basic.testis a few dozen bytes, which is why this was not caught.Fix
Two parts:
Skip the header while scanning for row boundaries on the first fill, keeping it in the buffer so
CheckHeaderstill validates it. Subsequent fills start at a real row boundary and are unchanged.Treat the header itself as a valid first boundary. The first fill only has
buffer_size - 19bytes available after the header, so a first row larger than that but no larger thanbuffer_sizewould otherwise still be rejected even though it fits. When no complete row fits alongside the header, the parser is handed just the header and the partial row carries over to the next fill, where the whole buffer is available.CheckHeader's bound goes from>=to>so a buffer holding exactly the header is accepted. A row therefore only has to fit inbuffer_size, not inbuffer_size - 19.COPY_FILE_HEADER_SIZEis derived fromPostgresConversion::COPY_HEADER_LENGTHso it stays in step with whatCheckHeaderconsumes.Verification
FindLastCompleteRow, the patchedFillBufferloop andCheckHeaderwere reproduced verbatim in a standalone harness and driven with the exact byte streamsPostgresBinaryWriterproduces:buffer_size=19is a buffer of exactly the header)buffer_size - 18throughbuffer_size, for buffer sizes 32/40/48/56/64 — all succeed, covering the header-carry pathbuffer_size + 1bytes — still rejected withrow larger than the read bufferinvalid header; header-only files read as zero rowsTest
test/sql/misc/postgres_binary_read_buffer.test:buffer_size19, 24, 32 and 64, with an exact round trip viaEXCEPTbuffer_size=64(larger thanbs - 19, smaller thanbs) and atbuffer_size=56(exactly one row per buffer)buffer_size=64, which must still raiserow larger than the read bufferBehaviour notes for review
invalid header. The parser already accepts files that end after N rows without a footer, so this matches existing leniency rather than adding new — but it is a change; say the word if footer validation is preferred.buffer_sizesmaller than the header itself (< 19), the constructor still fails, but withinvalid headerrather than a message about the buffer being too small. The header is valid in that case — the buffer just cannot hold it. Pathological configuration; left as is to keep the change minimal.valid = totalfallback at EOF (which tolerates trailing garbage in the last buffer) is preserved unchanged.