Skip to content

[SPARK-58258][PYTHON][SQL] Gate the columnar Arrow Python UDF passthrough on the vector shape matching the declared schema#57428

Open
viirya wants to merge 8 commits into
apache:masterfrom
viirya:arrow-udf-passthrough-shape-gate
Open

[SPARK-58258][PYTHON][SQL] Gate the columnar Arrow Python UDF passthrough on the vector shape matching the declared schema#57428
viirya wants to merge 8 commits into
apache:masterfrom
viirya:arrow-udf-passthrough-shape-gate

Conversation

@viirya

@viirya viirya commented Jul 22, 2026

Copy link
Copy Markdown
Member

What changes were proposed in this pull request?

The columnar Arrow Python UDF input fast path (ColumnarArrowPythonInput.writeArrowDirect) forwards upstream vectors' buffers verbatim into the worker's IPC stream, whose Schema message was written once from the declared UDF input schema using the standard interchange encoding. Its eligibility check (isArrowBacked) only verified isInstanceOf[ArrowColumnVector] -- not that each selected vector's physical shape matches what that schema declares. This PR adds ArrowUtils.isInterchangeShapedField, a recursive check for shapes a Spark interchange schema cannot declare:

  • the lossless internal struct encodings of nanosecond timestamps and CalendarInterval (SPARK-57975 / SPARK-58005, produced by the Arrow-cached scan),
  • var-width vectors whose offset width disagrees with spark.sql.execution.arrow.useLargeVarTypes,
  • the Arrow view types (readable through ArrowColumnVector but never declared by Spark schemas),

and requires it in the passthrough eligibility check. Mismatched batches take the existing writeRowByRow fallback, which re-encodes through ArrowWriter under the declared schema -- including its DATETIME_OVERFLOW guards for values the interchange encoding cannot represent at all.

Why are the changes needed?

An IPC consumer decodes a RecordBatch body strictly by the shape the stream's Schema message declared, so forwarding a mismatched vector produces a stream whose header and body disagree: depending on the column mix that surfaces as a worker-side decode error or, worse, silently mis-bound buffers handed to the UDF. This is reachable today: the Arrow cache (SPARK-57268) always stores standard 32-bit var-width offsets, so caching a string column and running an Arrow-optimized Python UDF over it with useLargeVarTypes=true corrupts the stream -- the new test fails with a worker-side error without this gate and passes with it. The lossless struct encodings are the same hazard one step ahead: they become reachable the moment the Python side supports nanosecond timestamps or CalendarInterval as UDF inputs, and the gate closes that door now.

The safety argument for the passthrough is compositional: every interchange-shaped vector was produced under the guarded write boundary (its values already fit the encoding), so the forwarding point only needs to verify shape -- which this PR makes explicit instead of implicit.

Does this PR introduce any user-facing change?

No. Shape-matching batches keep the zero-copy fast path; mismatched ones fall back to the row-based re-encoding, which is the correct-by-construction slow path.

How was this patch tested?

  • ArrowUtilsSuite "isInterchangeShapedField rejects shapes the interchange schema cannot declare": interchange-encoded fields of every kind pass (including standard nanos/interval encodings and nested complex types); the lossless struct encodings are rejected top-level and nested; an untagged struct with the same child names passes; var-width offset width is checked against largeVarTypes in both directions and nested; view types are rejected.
  • pyspark.sql.tests.arrow.test_arrow_python_udf_cached gains test_udf_on_cached_strings_with_large_var_types: an Arrow-optimized Python UDF over an Arrow-cached string column with useLargeVarTypes=true. Verified it fails with a worker-side error without the gate and passes with it; the existing default-config tests confirm shape-matching batches still take the fast path and produce correct results.

Was this patch authored or co-authored using generative AI tooling?

Generated-by: Claude Code

This pull request and its description were written by Claude Code.

case ArrowType.Utf8.INSTANCE | ArrowType.Binary.INSTANCE => !largeVarTypes
case ArrowType.LargeUtf8.INSTANCE | ArrowType.LargeBinary.INSTANCE => largeVarTypes
case ArrowType.Utf8View.INSTANCE | ArrowType.BinaryView.INSTANCE |
ArrowType.ListView.INSTANCE =>

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we need to block LargeListView together to be safe?

Suggested change
ArrowType.ListView.INSTANCE =>
ArrowType.ListView.INSTANCE | ArrowType.LargeListView.INSTANCE =>

Also, I'm wondering if we need to block ArrowType.LargeList.INSTANCE in the same way to be safe.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Both are real gaps, and your "do we also need X" question pointed at the deeper problem: a blocklist of known-bad shapes can never be complete, and its failure direction is stream corruption. I flipped the check to an allowlist of the shapes toArrowField can actually produce (with exact parameter checks -- int widths, decimal bit width, timestamp/time/duration units), so LargeListView, LargeList, and everything else Spark never declares (unions, FixedSizeList, Decimal256, future types) are rejected by construction rather than by enumeration. The failure directions are asymmetric -- a wrongly rejected shape merely takes the row-based fallback (slower, still correct), a wrongly forwarded one corrupts the stream -- so the conservative polarity is the right one; this is now recorded in the doc comment. Added rejection tests for LargeList, ListView, LargeListView, and FixedSizeList.

@uros-b

uros-b commented Jul 22, 2026

Copy link
Copy Markdown
Member

Thank you @viirya and @dongjoon-hyun!

// declares; plain structs are fine (their children are checked below).
!(isTimestampNanosStructField(field) || isCalendarIntervalStructField(field))
case ArrowType.Bool.INSTANCE | ArrowType.Null.INSTANCE | ArrowType.List.INSTANCE => true
case i: ArrowType.Int => i.getIsSigned && Set(8, 16, 32, 64).contains(i.getBitWidth)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since isInterchangeShapedField is a hot-path which is invoked at all field tree every batch, shall we define Set(8, 16, 32, 64) somewhere and reuse this?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hoisted to an object-level private val interchangeIntBitWidths with a comment noting why (per-field-tree per-batch call site).

case _: ArrowType.Map => true
case _ => false
}
shapeOk &&

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shall we add field.getDictionary == null because a dictionary-encoded vector's record batch carries indices and needs separate dictionary batches this stream never writes, so it must take the fallback even though its index type alone would pass the shape check above? Although Spark doesn't generate those data, we assume DSv2 data source, right?

Suggested change
shapeOk &&
shapeOk &&
field.getDictionary == null &&

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added as suggested. You're right that the DSv2 assumption makes this reachable: Spark never produces dictionary-encoded vectors, but an Arrow-backed connector may, and its record batches carry only indices -- the dictionary batches live in messages this stream never writes, so the field must take the fallback even when its type passes the shape check. Documented in the doc comment and covered by a test pairing a dictionary-encoded field (rejected) with the same field undictionaried (passes), isolating the dictionary as the reason.

@dongjoon-hyun

Copy link
Copy Markdown
Member

Could you rebase once more, @viirya ? master branch was broken at compilation and now it's recovered.

@sunchao sunchao left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One verified correctness issue; see the inline [P1] comment.

val shapeOk = field.getType match {
case ArrowType.Utf8.INSTANCE | ArrowType.Binary.INSTANCE => !largeVarTypes
case ArrowType.LargeUtf8.INSTANCE | ArrowType.LargeBinary.INSTANCE => largeVarTypes
case _: ArrowType.Struct =>

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P1] Validate the complete declared struct shape before forwarding buffers.

This branch accepts any Struct as long as each child independently passes the allowlist. isGeometryField/isGeographyField (and isVariantField) recognize a tagged struct containing the required canonical two children even when an extra child follows, so ArrowColumnVector still reports the correct Spark logical type and its normal accessor reads the correct first two children. However, the UDF stream schema declares only those two children, while writeArrowDirect serializes the additional child's buffers too. An Arrow reader then silently consumes that extra child as the following selected argument: I reproduced an extra int32 child containing 7 followed by an actual int32 argument containing 123, and the argument decoded as 7 without raising an exception.

Could we compare the complete incoming field tree with the corresponding declared UDF field (including exact child count, ordering, and physical types), or reject noncanonical tagged Variant/Geometry/Geography structs before taking the direct path? A regression with a trailing scalar argument should catch the silent buffer shift.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reproducing the silent shift settles it -- thank you. You're right that per-child validation cannot be sound when the recognizers tolerate extra children, and rather than special-casing the tagged structs I took your first suggestion in full: the check is now isCompatibleWithDeclaredField(actual, declared), a structural congruence walk (same Arrow type, same child count, congruent children, no dictionary) of each selected vector's field tree against the corresponding field of root.getSchema -- the exact header the stream already sent. That turns "body matches header" from an approximation (the allowlist) into the literal check, and subsumes every case discussed on this PR: the lossless structs, the var-width/largeVarTypes disagreement (the parameter disappears -- the declared side carries it), view/large-list types, dictionary encoding, and your extra-child tagged structs (child-count inequality). The regression test builds your exact scenario: a geometry-tagged struct padded with an extra int32 child, asserted to still read back as GeometryType via the recognizers but to be incongruent with the canonical declared field.

viirya added 4 commits July 22, 2026 14:19
…ough on the vector shape matching the declared schema

The columnar Arrow Python UDF input fast path (writeArrowDirect)
serializes upstream vectors' buffers verbatim into a stream whose
Schema message was built from the declared UDF input schema with the
standard interchange encoding, but its eligibility check only verified
isInstanceOf[ArrowColumnVector] -- not that the vector's physical shape
matches what that schema declares. A mismatched shape produces a
stream whose header and body disagree, which the worker decodes as
garbage or rejects: reachable today with an Arrow-cached scan
(SPARK-57268) under spark.sql.execution.arrow.useLargeVarTypes=true
(the cache stores standard 32-bit var-width offsets while the stream
schema declares 64-bit), and, once the Python side supports them, via
the cache's lossless internal struct encodings of nanosecond
timestamps and CalendarInterval (SPARK-57975/SPARK-58005).

Add ArrowUtils.isInterchangeShapedField, which checks a field tree for
shapes the interchange schema cannot declare (the lossless internal
structs, var-width offset width disagreeing with largeVarTypes, and
the Arrow view types), and require it in the passthrough eligibility
check. Mismatched batches take the existing writeRowByRow fallback,
which re-encodes through ArrowWriter under the declared schema --
including its DATETIME_OVERFLOW guards for values the interchange
encoding cannot represent at all.

Co-authored-by: Claude Code
…rable shapes

Address review: a blocklist of known-bad shapes (view types) misses
LargeListView, LargeList, and every future never-declared type, and
its failure direction is stream corruption. Flip
isInterchangeShapedField to an allowlist of the shapes toArrowField
can produce, with exact parameter checks; anything unrecognized fails
the check and takes the row-based fallback, which is slower but
correct. Add rejection tests for LargeList, ListView, LargeListView,
and FixedSizeList.

Co-authored-by: Claude Code
… the int-width set

Address review: a dictionary-encoded vector's record batch carries
indices whose values live in separate dictionary batches the UDF
stream never writes, so such fields must take the row-based fallback
even when their type passes the shape check -- reachable via
Arrow-backed DSv2 connectors even though Spark itself never produces
dictionary-encoded vectors. Also hoist the int bit-width set out of
the per-field-tree per-batch hot path.

Co-authored-by: Claude Code
… instead of allowlisting shapes

Address review: per-child validation cannot be sound when the tagged
struct recognizers tolerate extra children -- a geometry-tagged struct
with an extra child reads back as GeometryType and every child passes
the allowlist, yet the declared canonical field has exactly two
children, so forwarding the three-child body silently shifts the
following column's buffers (reproduced by the reviewer as one argument
decoding another column's value with no error).

Replace the allowlist with isCompatibleWithDeclaredField, a structural
congruence walk (same Arrow type, same child count, congruent
children, no dictionary encoding) of each selected vector's field tree
against the corresponding field of root.getSchema -- the exact header
the stream already sent. This turns "body matches header" from an
approximation into the literal check and subsumes the lossless
structs, the var-width offset width (the largeVarTypes parameter
disappears; the declared side carries it), view and large-list types,
dictionary encoding, and extra-child tagged structs. The regression
test builds the reviewer's exact scenario and asserts the padded
struct still reads back as GeometryType but is incongruent with the
canonical declared field.

Co-authored-by: Claude Code
@viirya
viirya force-pushed the arrow-udf-passthrough-shape-gate branch from f9bb933 to c6d6d01 Compare July 22, 2026 21:31

@sunchao sunchao left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One verified performance issue; see the inline [P2] comment.

*/
def isCompatibleWithDeclaredField(actual: Field, declared: Field): Boolean = {
actual.getDictionary == null &&
actual.getType == declared.getType &&

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Do not treat timezone labels as a physical Arrow-layout mismatch.

This is reachable entirely within Spark: a scalar Arrow UDF returning TimestampType emits timestamp[us, tz=UTC], while a following Arrow-optimized regular Python UDF in an America/Los_Angeles session runs as a separate ArrowEvalPythonExec and declares timestamp[us, tz=America/Los_Angeles]. Both vectors represent Spark TimestampType and have identical int64 epoch-microsecond buffers, but ArrowType.Timestamp equality includes the timezone label, so this check rejects every batch and routes the complete input through writeRowByRow. That silently defeats the zero-copy optimization for a normal mixed-UDF pipeline.

ArrowFileReadWrite.checkLayoutMatch already handles the same case by comparing timestamp units while ignoring timezone labels; it also ignores Map.keysSorted because that flag does not affect the physical layout. Could we use equivalent physical-layout comparisons here, while preserving the timezone-present versus timezone-absent distinction between TimestampType and TimestampNTZType, and add a non-UTC mixed-UDF regression?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed following the ArrowFileReadWrite.checkLayoutMatch precedent you pointed at: the type comparison now goes through a physical-layout equivalence -- timestamps compare unit and timezone presence-vs-absence (the label is ignored; it differs across Spark's own paths exactly as you described, while presence distinguishes TimestampType from TimestampNTZType, whose identical-looking values are interpreted differently), Map.keysSorted is ignored, and everything else stays strict equality. Unit tests pin all four directions (label mismatch accepted, presence mismatch rejected both ways, unit mismatch rejected since forwarding one unit under the other reinterprets values by a factor of 1000), and the requested regression runs your exact pipeline -- a pandas UDF feeding an Arrow-optimized Python UDF under an America/Los_Angeles session -- end to end.

viirya added 2 commits July 22, 2026 15:47
…type equality

Address review: ArrowType.Timestamp equality includes the timezone
label, which differs across Spark's own paths (a worker's output comes
back labeled UTC while the next stream declares the session time
zone) without affecting the int64 layout -- strict equality silently
routed every batch of a normal mixed-UDF pipeline through the
row-based fallback. Following the ArrowFileReadWrite.checkLayoutMatch
precedent, compare timestamps by unit and timezone
presence-vs-absence (presence distinguishes TimestampType from
TimestampNTZType, whose values are interpreted differently) and
ignore Map.keysSorted, keeping strict equality elsewhere. Add unit
tests for all directions and a non-UTC mixed-UDF end-to-end
regression (pandas UDF feeding an Arrow-optimized Python UDF under an
America/Los_Angeles session).

Co-authored-by: Claude Code

@HyukjinKwon HyukjinKwon left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

0 blocking, 1 non-blocking, 0 nits.
The code and design are correct — a declared-field congruence check is the complete correctness condition, it mirrors and appropriately tightens the ArrowFileReadWrite.checkLayoutMatch precedent, and the test matrix is thorough (independent-provenance reject fixtures, both the P1 extra-child and P2 timezone regressions). The prior review threads (@sunchao P1/P2, @dongjoon-hyun dictionary/large-list) are all addressed in this revision — nothing re-raised. One non-blocking item on the writeup only.

Design / architecture (1)

  • PR description describes a superseded design — see ## PR description suggestions below.

Verification

Traced the congruence predicate against the declared field: comparing against the exact stream header (not an allowlist) makes every hazard on this PR a rejection by construction — the useLargeVarTypes offset-width disagreement becomes Utf8-vs-LargeUtf8 type inequality; the lossless nanos/interval structs, view/large-list types, and dictionary encoding are type/child/dictionary mismatches; and @sunchao's tagged-struct extra-child shift is caught by child-count equality (the recognizers tolerate the extra child but the declared field has exactly two). sameLayoutArrowType correctly ignores the timestamp tz label and Map.keysSorted (no layout impact) while keeping tz presence (TimestampType vs TimestampNTZType) and unit. The isArrowBacked index mapping is sound: declaredFields.get(i) and the i-th selected vector both follow inputColumnIndices order. False-reject is a slower correct fallback; false-accept is corruption — the conservative polarity is right.

PR description suggestions

  • Update the description to describe the shipped ArrowUtils.isCompatibleWithDeclaredField design (a recursive congruence-against-the-declared-field walk), not isInterchangeShapedField / "a recursive check for shapes a Spark interchange schema cannot declare". The latter method name appears nowhere in the diff — the design was flipped from the original allowlist to a congruence check after @sunchao's P1, and the description is the pre-flip version. Since it lands as the permanent commit message, it should match the code (which even notes "This compares against the declared field rather than allowlisting").
  • Fix the cited Scala test name: it's "isCompatibleWithDeclaredField requires congruence with the declared field tree", not "isInterchangeShapedField rejects shapes the interchange schema cannot declare".

@sunchao sunchao left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One verified regression-test issue; see the inline [P2] comment.

# regardless of which path is taken.
ts = datetime.datetime(2025, 1, 6, 12, 30, 45)
with self.sql_conf({"spark.sql.session.timeZone": "America/Los_Angeles"}):
df = self.spark.createDataFrame([(ts,)], schema="ts timestamp")

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Make the non-UTC regression exercise the columnar Arrow path

createDataFrame(...) is not cached here, so this input plans as RDDScanExec with supportsColumnar = false. Both chained ArrowEvalPythonExec operators therefore take super.doExecute() rather than doExecuteColumnar(), and ColumnarArrowPythonInput.isArrowBacked / ArrowUtils.isCompatibleWithDeclaredField never run. Consequently this test still passes if the timezone-layout fix is reverted, so it does not cover the mixed-UDF false-rejection regression it describes. Please cache and materialize the input (and unpersist it), and assert that the physical plan actually uses the columnar path.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch -- and following it up uncovered two more layers, which reshaped the test.

You're right that the input planned as RDDScanExec, so neither eval node ever took the columnar path. But caching alone doesn't rescue the chained shape either: the two eval types plan as separate ArrowEvalPython nodes with a Project in between, and ProjectExec is row-based, so the second node never takes the columnar path -- the "worker output labeled UTC" pipeline I described cannot reach the gate through chaining at all today.

Digging further with an instrumented run: the cache cannot present a mismatched label either. ArrowCachedBatchToColumnarBatchIterator rebuilds its Arrow schema at read time from the query's session timezone, so a cache materialized under UTC and scanned under America/Los_Angeles hands the gate vectors already relabeled America/Los_Angeles -- actual and declared labels are equal by construction. As far as I can tell, no in-tree columnar producer can hit the timestamp-label clause today; it matters for external DSv2 Arrow sources (the audience of this columnar path), and its polarity stays pinned by the ArrowUtilsSuite cases.

Test changes accordingly: every test in the suite now runs against a cached, materialized (and unpersisted) input, and a shared helper walks the executed plan asserting the expected number of ArrowEvalPythonExec nodes and that each one supportsColumnar -- so the suite cannot silently stop exercising the columnar path again. The chained non-UTC test is replaced by a timestamp test that caches under UTC and runs the UDF under America/Los_Angeles, asserting the columnar plan and that the stored epoch round-trips across the timezone change.

One thing to be upfront about: even with the gate provably executing, reverting the layout fix would still not fail an e2e test -- a false reject falls back to the row path, which is correctness-preserving by design, so accept-vs-fallback is only observable at the unit level. If you think end-to-end observability is worth it, a small follow-up could add a pass-through batch count to the node's SQL metrics; happy to do that separately.

… columnar Arrow path

Every test in the cached-UDF suite now walks the executed plan and asserts
the expected number of ArrowEvalPythonExec nodes and that each one supports
columnar input, so the suite cannot silently stop exercising the code it
exists to pin. The chained non-UTC test is replaced by a cached timestamp
test running under a changed session timezone: the chained shape plans a
row-based Project between the two eval nodes and never reaches the gate,
and the cache rebuilds its Arrow schema at read time with the query's
session timezone, so the label-mismatch case has no in-tree columnar
producer and stays pinned by the ArrowUtilsSuite congruence tests.

Co-authored-by: Claude Code

@sunchao sunchao left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One verified silent-data-corruption issue; see the inline [P1] comment.

actual.getDictionary == null &&
sameLayoutArrowType(actual.getType, declared.getType) &&
actual.getChildren.size == declared.getChildren.size &&
actual.getChildren.asScala.zip(declared.getChildren.asScala).forall { case (a, d) =>

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P1] Preserve the semantic ordering of Arrow map-entry children

Ignoring child names here makes a valid Arrow Map<int,int> with entry children [value: int NOT NULL, key: int NOT NULL] pass against Spark's canonical [key, value] schema: the recursive positional pairs have identical Arrow types and child counts. Arrow 19 MapVector accepts that layout because it checks the two children and first-child nullability but does not validate their names/order, while Spark's ArrowColumnVector.MapAccessor reads key and value by name. I reproduced the full direct-IPC path with actual Arrow 19 vectors: the source map {123: 7} passed this predicate, but VectorUnloader plus the canonical stream header silently decoded it as {7: 123}. Please require canonical semantic key/value names at the corresponding map-entry positions (including nested maps), and add a swapped-entry regression; field names elsewhere can remain ignored.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice find -- confirmed and fixed. Map entries are indeed the one place where field names carry semantics rather than decoration: the Arrow spec binds key/value to the entry-struct children (key first, addressed as "key"/"value"), yet Arrow tolerates vectors with the children in the opposite order, and with matching key/value types the swapped tree is positionally indistinguishable from the canonical one.

isCompatibleWithDeclaredField now additionally requires, at every Map node, that the entry-struct children match the declared names positionally (mapEntryNamesMatchDeclared); the recursion covers nested maps. Names stay ignored everywhere else, as you suggested.

Regressions added at the unit level: your exact shape -- Map<int,int> with [value, key] entry children, where only the names reveal the swap -- is rejected, a nested map<int, map<int,int>> with swapped inner entries is rejected, and the canonical tree still passes. Negative-validated by removing the check and watching the test fail. Also added an e2e companion (test_udf_on_cached_map_column) pinning that canonical cached map vectors pass the gate and ride the columnar path.

An Arrow Map binds its key/value semantics to the entry-struct children
by name (the spec puts the key first and MapVector addresses the children
as "key"/"value"), yet Arrow tolerates vectors whose entry children sit
in the opposite order. With matching key and value types the swapped tree
is positionally indistinguishable from the canonical one, and forwarding
its buffers verbatim under the declared header silently swaps keys and
values. The gate now requires the entry children to match the declared
names positionally at every Map node; field names stay ignored everywhere
else, where Spark's semantics are positional and names carry no meaning.

Co-authored-by: Claude Code
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.

5 participants