fix: validate Arrow schema before import - #861
Conversation
Co-authored-by: Cursor <cursoragent@cursor.com>
|
Review at The bounds checking in the new FlatBuffers traversal is careful — 1. The recursion has no
|
|
Follow-up with the red arm run, and one finding that changes what this PR is Your red arm: only one of the two new checks is load-bearingMain's
It is not useless as a regression guard, but the PR body presents two arms as And the demonstration you are missing is much better than the one you haveThis PR fixes a silent data-corruption bug on An 8-byte That is a far stronger argument for this PR than the arm you shipped: not Sequencing#861 and #862 conflict in Still open from my earlier reviewThe Not approving — same account. |
jdatcmd
left a comment
There was a problem hiding this comment.
Reviewed adversarially at 04d44f1, three independent lenses plus a refutation pass. Ten findings survived; these are the four that matter.
BLOCKING: the float precision default is HALF, not DOUBLE
case A_FLOAT64:
if (imp_i16_field(b, len, type, 0, 2) != 2) /* default 2 = DOUBLE */
return false;imp_i16_field(..., int16 def) returns def when the field is absent. Arrow's Schema.fbs declares enum Precision:short { HALF, SINGLE, DOUBLE } with no explicit field default, so an omitted precision means HALF (0) — the value a writer omits.
So a float16 column whose precision field is not written passes this check against a float8 target, and the importer then reads 8-byte doubles out of 2-byte data. The check that exists to catch a same-tag mismatch admits the one case where the file says nothing.
0 is the correct default, and the arm should then require 2.
MAJOR: the whole per-kind parameter block has no red arm
src/columnar_arrow.c:1565-1613 — int bit width and signedness, float precision, date unit, time unit and width, timestamp unit and timezone, UUID width, decimal precision/scale/width. Disable all of it and the suite does not notice:
sed -i '1565s/switch (n->kind)/switch ((ArrowKind) -1)/' src/columnar_arrow.c
test/arrow_import.sh -> accounting: 21 passed + 0 failed + 0 unrunnable = 21 PASSED
The mutation is load-bearing rather than inert — the same probe file, imported on both builds:
PR build u64->bigint REJECTED 42804 | ts('ms')->timestamp REJECTED | decimal128(10,2)->numeric(20,4) REJECTED
mutated u64->bigint ACCEPTED "1,2" | ts('ms')->timestamp ACCEPTED, values 1000x wrong
| decimal128(10,2)->numeric(20,4) ACCEPTED, 1.00 stored as 0.0100
That is silent data corruption on three separate types, and the suite stays green through all of it. The single new scalar arm cannot see any of it, because float64-into-bigint differs in the FlatBuffers tag and is caught by the first switch alone. The round-trip arms cannot either — they only ever feed pgColumnar's own schema back to itself, which matches under a relaxed check just as well.
Four fixtures close it, each asserting 42804: uint64 into bigint, timestamp('ms') into timestamp, timestamp(tz) into a naive timestamp, decimal128(10,2) into numeric(20,4).
MAJOR: "reject nested schema mismatch" is green with the whole fix reverted
Your own Tests section says it: 20 passed, 1 failed on origin/main with only the test change. Two checks were added and only one goes red. The nested arm passes on unmodified main because the pre-existing #214 offset-bounds check fires first — XX001 data_corrupted, "string/binary data runs past its buffer" — and expect_error cannot tell XX001 from 42804.
The nested recursion the comment claims it pins is never even reached for that fixture: target column b is text → A_UTF8 → wanttag = Utf8, the file's field is List, so if (tag != wanttag) return false fires before the children loop. The recursion can be deleted wholesale and the arm stays green.
sqlstate_or_hang already exists in this file at line 33 and already returns a bare SQLSTATE. One substitution fixes it:
check "reject nested schema mismatch" \
"$(sqlstate_or_hang "SELECT pgcolumnar.import_arrow('ri_nested_mismatch','$MISMATCHF')")" "42804"That is red on main (XX001 != 42804) and green here.
MAJOR: a dictionary-encoded field is validated as its value type
imp_schema_field_matches reads Field slots 2 (type_type), 3 (type) and 5 (children), and never slot 4 (dictionary). A dictionary-encoded field is therefore checked against its value type while its RecordBatch buffers hold index values. The existing dictionary rejection elsewhere is what saves this today; the new validator does not, and it is presented as complete.
Two smaller ones
Decimal precision is over-strict. The A_DECIMAL128 arm requires the file's Decimal.precision to equal the target's declared precision, but precision has no effect on the Decimal128 buffer layout — 16-byte little-endian int128 at the given scale. Scale and bit width must match; precision equality rejects files that would import correctly.
The third summary bullet has no check. "Harden FlatBuffers table/vector offset traversal" — deleting all five added bounds guards leaves the suite at 21 passed, 0 failed.
What is right
The tag switch itself is correct and the scalar arm does pin it. imp_i16_field/imp_bool_field reading a FlatBuffers default when a field is absent is the right shape — the defect is the value chosen for one of them, not the mechanism. And splitting validation out of the decode path so a mismatch is refused before any buffer is read is the right structure for this fix.
|
I tried to empirically confirm the blocking finding and could not. Reporting The claim is about a What I ran: built a That does not test the finding, for two reasons, and I would rather say so
Producing the case needs a hand-built FlatBuffers stream with the slot left out. What I can say from here:
If it declares none, you are right and Two things from my side that your review does not coverNeither is a criticism — they are findings this PR earns and does not claim:
Measured across branches for both: Combined with your |
|
Cross-reference, not a review of this PR's code: #870 fixes #864/#865 and The interaction is worth settling before either merges, because it is a contract This PR rejects schema/layout mismatches before decoding. #870 makes the importer The point that matters for this PR: What is genuinely this PR's and not #870's: the non-temporal mismatches. The So the two PRs are complementary if this one keeps its non-temporal validation and I have not run this branch's current head, so the above is about the stated scope Posted as OffgridwithJD; not approving, same account as the author. |
Requested on review. imp_i16_field(..., def) returns def when the field is ABSENT,
and Arrow's Schema.fbs declares `enum Precision:short { HALF, SINGLE, DOUBLE }`
with no explicit default -- so an omitted precision means HALF (0). Passing 2 as
the default let a float16 file whose precision field is not written satisfy the
check against a float8 column, and the reader then took 8-byte doubles out of
2-byte data. Both the float4 and the float8 arms had it; both are fixed.
The per-kind parameter block also had no red arm. Disabling it left the suite
green at 21 passed, 0 failed. With the arms added, the same mutation reddens 13:
reject float16 into float8 (42804) reject uint64 into bigint (42804)
reject timestamp[ms] into timestamp reject date64 into date
reject timestamp[us,UTC] into timestamp reject timestamp[us] into timestamptz
Every arm asserts the SQLSTATE rather than that the call failed.
This PR now overlaps #870 semantically, and resolving the text would decide a behaviour question by accidentNot asking for a merge decision. Asking which behaviour we want, because the two ways of resolving this conflict are not equivalent and neither is obviously "the rebase". #870 merged into
The thing that makes this worth a ruling rather than a judgement call: Either may be right. Refusing is defensible if we would rather not silently lose sub-microsecond precision. Accepting is defensible if we would rather read the common file and document the narrowing. What is not defensible is picking one by resolving a merge conflict, because the person doing the resolve is choosing the product's behaviour while thinking they are choosing between two hunks. Also note the CI status on this PR is not evidence. Its only workflow run is against a commit that is no longer its head:
GitHub Actions fired nothing between roughly 20:54Z and 01:00Z, so the pushes at 21:40–21:41 produced no runs. The outage explains why there is no run at the current head; it does not make the displayed green tick mean what a reader would take it to mean. Whichever way the behaviour question is ruled, these two need a re-trigger before anyone reads their status. Holding off on resolving until someone rules. |
Summary
Reproduction
On current
origin/main, the added test imports a PyArrowfloat64array into a pgColumnarbigintcolumn successfully, silently interpreting the IEEE-754 bits as integers. The red arm reports:FAIL reject equal-width scalar type mismatch (expected error): got [succeeded] want [error]Tests
test/arrow_import.sh /usr/bin/pg_config(PostgreSQL 18.6, Ubuntu 26.04): 21 passed, 0 failedorigin/mainwith only the test change: 20 passed, 1 failed