[SPARK-59598][SQL] GetMapValue and ElementAt must not match a null map key - #58875
Open
qingfureal wants to merge 1 commit into
Open
qingfureal wants to merge 1 commit into
qingfureal wants to merge 1 commit into
Conversation
…p key GetMapValue (m[k]) and ElementAt (element_at(m, k)) share their key lookup in GetMapValueUtil. When the map's key array contains a null, the lookup matches that null key against the key type's zero value and returns its value instead of NULL. Two independent mechanisms cause this, so it reproduces with codegen both on and off: 1. The interpreted linear scan compares with the key type's natural ordering, which unboxes its arguments for a primitive key type; Scala's BoxesRunTime.unboxToInt(null) is 0, so equiv(null, 0) is true. 2. The generated code reads the candidate key with a primitive getter, which is not null-aware and returns 0 for a null slot. This affects the generated linear scan, and also the hash probe added by SPARK-55959, whose bucket table hashes the unboxed null into the same bucket as 0. Null map keys are reachable because the file-format readers construct ArrayBasedMapData directly rather than through ArrayBasedMapBuilder; ParquetRowConverter documents the case as undefined behavior in the tree. The lookup key is never null, since both expressions are null-safe on the ordinal, so a null map key can never be the key being looked up. Skip null keys on all four paths: the interpreted and generated linear scans, and the driver-side hash bucket table and hash index.
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.
What changes were proposed in this pull request?
GetMapValue(m[k]) andElementAt(element_at(m, k)) share their key lookup inGetMapValueUtil. This PR makes that lookup skip null entries in the map's key array, onevery path it has:
LinearExecutor.eval— the interpreted linear scan.LinearExecutor.genCode— the generated linear scan.buildHashBuckets— the driver-side bucket table backing the generated hash probe.buildHashIndex— the driver-side index backing the interpreted hash lookup (defensive;a null
HashMapkey could not be matched anyway, but the two structures should describethe same set of keys).
The lookup key is never null — both expressions are null-safe on the ordinal — so a null map
key can never be the key being looked up, and skipping it is the correct semantics.
Why are the changes needed?
A map whose key array contains a null currently returns that null key's value for a lookup of
the key type's zero value (
0,0L,false, ...). For a map{null: 10, 1: 20}:Two independent mechanisms produce this, so it reproduces with codegen both on and off:
Interpreted linear scan.
ordering.equiv(keys.get(i, keyType), ordinal)compares usingthe key type's natural ordering.
keys.getis null-aware and correctly returnsnullfor anull slot, but for a primitive key type the ordering unboxes its arguments, and Scala's
BoxesRunTime.unboxToInt(null)is0. Soequiv(null, 0)istrue.Generated code. The candidate key is read with a primitive getter
(
CodeGenerator.getValueemitskeys.getInt(i)), which is not null-aware and returns0for a null slot. This affects the linear scan, and also the hash probe added by SPARK-55959:
buildHashBucketshashes the unboxed null to the same bucket as0, so the probe finds thenull key and then compares
0 == 0.Maps with null keys are reachable.
ArrayBasedMapBuilderrejects them, but the file-formatreaders construct
ArrayBasedMapDatadirectly and do not.ParquetRowConvertersays so in thetree today:
OrcDeserializerandAvroDeserializerlikewise build the key array without a runtime nullcheck (each carries a comment asserting its format cannot produce one), and the Hive
MapObjectInspectorunwrapper inHiveInspectorsapplies the key unwrapper with no null checkat all. So a null key can reach the lookup from Parquet, ORC, Avro or Hive data.
"Undefined behavior" for such a map is one thing; silently returning a wrong, non-null value
for an unrelated lookup key is another, and it is not detectable by the user. Skipping null
keys makes the result well-defined and consistent across all four paths, without changing
anything for maps that have no null keys.
Note on cost: the linear scan now performs one
isNullAtper candidate key. That is a bit testfor
UnsafeArrayDataand a reference compare forGenericArrayData. It cannot be hoisted orgated, because
MapTypehas nokeyContainsNullflag to gate on (unlikevalueContainsNull).The hash paths take no new runtime cost — the filtering happens once, on the driver, at
construction time.
Does this PR introduce any user-facing change?
Yes, for maps that contain a null key, which previously had undefined behavior here.
Given a map
{null: 10, 1: 20}read from Parquet/ORC/Avro/Hive,m[0]andelement_at(m, 0)returned10; they now returnNULL. Lookups of keys that are actuallypresent are unaffected (
m[1]returns20before and after), as are all maps without nullkeys.
How was this patch tested?
New test
map lookup must not match a null keyinComplexTypeSuite, covering bothGetMapValueandElementAt, both executors (LinearExecutorvia a non-foldable map,PrebuiltHashExecutorvia a foldable one, with the strategy asserted rather than assumed), andboth a primitive (
IntegerType) and a non-primitive (StringType) key type. It also assertsthat a non-null key in the same map still resolves, i.e. the scan skips the null slot rather
than stopping at it.
checkEvaluationexercises the interpreted, codegen and unsafe-projectionpaths.
The test fails on
masterand passes with this change. Each hunk was confirmed to beload-bearing by reverting it individually:
Incorrect evaluation (codegen off): input[0, map<int,int>, true][0], actual: 10, expected: nullLinearExecutor.genCodeonlyIncorrect evaluation (fallback mode = CODEGEN_ONLY): input[0, map<int,int>, true][0], actual: 10, expected: nullbuildHashBucketsonlyIncorrect evaluation (fallback mode = CODEGEN_ONLY): map(keys: [null,1], values: [10,20])[0], actual: 10, expected: nullAlso ran
ComplexTypeSuite,CollectionExpressionsSuite,MapDataSuiteand the optimizer'sComplexTypesSuite(122 tests, 7 suites) plusscalastyleonsql/catalyst: all pass.Was this patch authored or co-authored using generative AI tooling?
Co-Authored: Claude Code (Opus 5)