david-mollitor-db opened a new pull request, #59162:
URL: https://github.com/apache/spark/pull/59162

   ### What changes were proposed in this pull request?
   
   `CollectFrequentItems`, the aggregate behind `DataFrame.stat.freqItems`, 
keeps its counters in a
   `mutable.Map[Any, Long]` keyed by the column value. A `BinaryType` value is 
an `Array[Byte]`, and
   Java arrays use referential equality and identity hash codes. Each row's 
value is a separate
   array, so equal binary values never match.
   
   This PR keys binary values by a small private `BinaryKey` wrapper instead. 
The wrapper compares and
   hashes the bytes by content (`java.util.Arrays.equals` / `hashCode`, the 
hash computed once), and
   it wraps the array without copying it:
   
   ```scala
   private final class BinaryKey(val bytes: Array[Byte]) {
     private[this] val hash = java.util.Arrays.hashCode(bytes)
     override def hashCode(): Int = hash
     override def equals(other: Any): Boolean = other match {
       case that: BinaryKey => java.util.Arrays.equals(bytes, that.bytes)
       case _ => false
     }
   }
   ```
   
   - `update` wraps each binary value as it is added: 
`toKey(InternalRow.copyValue(key))`.
   - `deserialize` wraps binary keys again as it reads them.
   - `eval` and `serialize` unwrap them, so the output type and the serialized 
buffer format are
     unchanged.
   - Every other value is its own key, as before.
   
   Not copying the array is as safe as the current code, which already stores 
the array itself:
   `InternalRow.copyValue` returns an `Array[Byte]` unchanged.
   
   `CollectSet` solves the same problem by keying binary values with an 
`UnsafeArrayData` copy of
   their bytes. Here that would add a copy of every input row's value; the 
wrapper adds only one
   small object per row.
   
   ### Why are the changes needed?
   
   It is a correctness bug. Because equal binary values never match, their 
counts never exceed 1, and
   once the map is full the eviction step keeps clearing it. The result depends 
on the number and
   order of rows rather than on how frequent a value is. A value can come back 
several times, or be
   missing even when it is in every row. That breaks `freqItems`' documented 
guarantee: false
   positives are possible, but false negatives are not.
   
   ```scala
   Seq(11, 12).foreach { n =>
     val df = Seq.fill(n)(Array[Byte](1, 2)).toDF("b").coalesce(1)  // every 
row is the same value
     println(df.stat.freqItems(Seq("b"), 
0.5).collect().head.getSeq[Array[Byte]](0).map(_.toSeq))
   }
   // before: n=11 -> ArraySeq(ArraySeq(1, 2), ArraySeq(1, 2)), n=12 -> 
ArraySeq()
   // after:  ArraySeq(ArraySeq(1, 2)) for both
   ```
   
   Binary columns come up in practice. The `key` and `value` columns of a batch 
Kafka read are
   `BinaryType`, so looking for hot keys with `freqItems` hits exactly this 
case. Parquet files from
   systems that don't annotate strings are read as binary unless 
`spark.sql.parquet.binaryAsString`
   is set. Hash digests and UUIDs are often stored as binary too.
   
   The bug dates back to the original implementation (SPARK-7242, 1.4.0), which 
keyed a
   `Map[Any, Long]` by `row.get(i)`. The rewrite as an aggregate (SPARK-40900, 
3.4.0) kept the same
   keying.
   
   Other key types are not affected. `UTF8String`, `BinaryView` (used by 
`GEOMETRY` / `GEOGRAPHY`),
   `UnsafeRow`, `UnsafeArrayData` and `GenericArrayData` all compare by 
content, including binary
   values nested in a struct or array.
   
   ### Does this PR introduce _any_ user-facing change?
   
   Yes, a bug fix. `freqItems` on a `BinaryType` column now counts equal values 
together, so it
   returns each frequent value once and no longer misses frequent values. 
Results for other column
   types are unchanged.
   
   ### How was this patch tested?
   
   New tests in `DataFrameStatSuite`, both of which fail without the fix:
   - `SPARK-59898: freqItems compares BinaryType values by content`, which is 
end to end:
     - A column where every row is the same binary value (10, 11 and 12 rows) 
returns that value
       exactly once. Without the fix it returned `[[1, 2], [1, 2]]`.
     - A binary value in half of the rows of a column spread over 4 partitions 
is found. This path
       also serializes and merges partial results.
   - `SPARK-59898: collect_frequent_items keys BinaryType values by content`, 
which calls the
     aggregate directly. Three updates with the same bytes plus a null give two 
entries, before and
     after a `serialize`/`deserialize` round trip and a `merge`. Without the 
fix there were three
     separate `[1, 2]` entries.
   
   `build/sbt 'sql/testOnly org.apache.spark.sql.DataFrameStatSuite'` passes 
(27 tests), and
   scalastyle is clean.
   
   This overlaps #59145 (SPARK-59896), which changes the same lines in 
`update`; whichever merges
   second needs a small rebase. When they are combined, binary values still 
have to be wrapped before
   the lookup, since the lookup itself needs content equality.
   
   ### Was this patch authored or co-authored using generative AI tooling?
   
   Generated-by: Claude Opus 5.5
   
   This pull request and its description were written by Isaac.
   


-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to