MaxGekk commented on code in PR #58850:
URL: https://github.com/apache/spark/pull/58850#discussion_r4029330936


##########
sql/core/src/test/scala/org/apache/spark/sql/execution/columnar/ArrowCachedBatchSerializerSuite.scala:
##########
@@ -1243,6 +1243,85 @@ class ArrowCachedBatchSerializerSuite extends QueryTest 
with SharedSparkSession
 
   // Helper: cache a single-column DataFrame (row path) and return its 
ArrowCachedBatch stats.
   // Stats layout per column: [lowerBound(0), upperBound(1), nullCount(2), 
rowCount(3), size(4)].
+  // Helper: the InMemoryRelation behind a cached DataFrame, after populating 
the cache. The
+  // DataFrame's queryExecution is memoized, so this must run before anything 
else forces it.
+  private def cachedRelation(df: org.apache.spark.sql.DataFrame): 
InMemoryRelation = {
+    df.cache()
+    df.count()
+    df.queryExecution.executedPlan.collectFirst {
+      case scan: InMemoryTableScanExec => scan.relation
+    }.get
+  }
+
+  // Helper: (Arrow vector class, row count, null count) per cached batch of a 
single-column
+  // relation, read back through the serializer's own columnar path.
+  private def cachedVectors(relation: InMemoryRelation): Array[(String, Int, 
Int)] = {
+    val attrs = relation.output
+    relation.cacheBuilder.serializer
+      .convertCachedBatchToColumnarBatch(
+        relation.cacheBuilder.cachedColumnBuffers, attrs, attrs, 
spark.sessionState.conf)
+      .mapPartitions { batches =>
+        batches.map { batch =>
+          val column = batch.column(0).asInstanceOf[ArrowColumnVector]
+          (column.getValueVector.getClass.getSimpleName, batch.numRows(), 
column.numNulls())
+        }
+      }
+      .collect()
+  }
+
+  private val nullPatterns: Seq[(String, Int => Boolean)] = Seq(
+    ("every 31st row null", i => i % 31 == 0),
+    ("no nulls", _ => false),
+    ("all nulls", _ => true))
+
+  test("SPARK-59571: TIME keeps its precision and its nulls through the cache, 
at every " +
+      "precision") {
+    // Every precision of TIME is written to the same TimeNanoVector; the 
precision travels in
+    // the Arrow field metadata and comes back through the cached relation's 
schema, so the
+    // cached type is checked as well as the values. The values are truncated 
to the declared
+    // precision so a precision loss could not hide behind a value the type 
would round anyway.
+    val rows = 1000
+    val nanosPerDay = 86400000000000L
+    for (precision <- Seq(0, 3, 6, 9); (pattern, isNull) <- nullPatterns) {
+      val unit = math.pow(10, 9 - precision).toLong
+      def timeAt(i: Int): LocalTime = {
+        val nanos = ((i.toLong * nanosPerDay) / rows + i.toLong * 1234567L) % 
nanosPerDay
+        LocalTime.ofNanoOfDay(nanos / unit * unit)
+      }
+      val values = (0 until rows).map(i => if (isNull(i)) null else timeAt(i))
+      val df = singlePartDf(values, TimeType(precision))
+      val relation = cachedRelation(df)
+      checkAnswer(df, values.map(Row(_)))
+      assert(relation.output.head.dataType === TimeType(precision),
+        s"TIME($precision), $pattern: the cached relation must keep the 
precision")
+      val vectors = cachedVectors(relation)
+      assert(vectors.map(_._1).toSet === Set("TimeNanoVector"), 
s"TIME($precision), $pattern")
+      assert(vectors.map(_._2).sum === rows, s"TIME($precision), $pattern")
+      assert(vectors.map(_._3).sum === (0 until rows).count(isNull),
+        s"TIME($precision), $pattern: null count")
+      df.unpersist()
+    }
+  }
+
+  test("SPARK-59571: day-time intervals of both signs keep their microseconds 
through the " +
+      "cache") {
+    val rows = 1000
+    for ((pattern, isNull) <- nullPatterns) {
+      // Whole microseconds, negative for the first half of the rows and 
positive for the rest.
+      def intervalAt(i: Int): Duration = Duration.ofNanos((i.toLong - rows / 
2) * 7001000000L)
+      val values = (0 until rows).map(i => if (isNull(i)) null else 
intervalAt(i))
+      val df = singlePartDf(values, DayTimeIntervalType())
+      val relation = cachedRelation(df)
+      checkAnswer(df, values.map(Row(_)))
+      assert(relation.output.head.dataType === DayTimeIntervalType(), pattern)
+      val vectors = cachedVectors(relation)
+      assert(vectors.map(_._1).toSet === Set("DurationVector"), pattern)
+      assert(vectors.map(_._2).sum === rows, pattern)
+      assert(vectors.map(_._3).sum === (0 until rows).count(isNull), 
s"$pattern: null count")
+      df.unpersist()
+    }
+  }
+

Review Comment:
   Both points are right, and I traced each against master rather than taking 
them on trust. Fixed in 96d1ab9.
   
   On the guard: agreed, the three assertions did not establish it. 
`relation.output` is `child.output`, fixed at cache time by 
`InMemoryRelation.apply`, so that assertion only re-read the input schema; the 
read path rebuilds its Arrow schema from `cacheSchema` via 
`ArrowUtils.toArrowSchema(..., losslessInternalTypes = true)` and the payload 
is the record batch alone, so nothing about the precision travels in the cached 
bytes (and `isCompatibleWithDeclaredField` ignores field metadata on the write 
side too); and all four precisions land in `TimeNanoVector` with the value 
untouched while the test pre-truncates its inputs, so neither `checkAnswer` nor 
the vector-class check could separate them.
   
   There is a metadata round trip in the cache read path, though, one level 
below where the test was looking: `new ArrowColumnVector(vector)` is 
`this(ArrowUtils.fromArrowField(vector.getField()))`, and the vector's field is 
the one `toArrowSchema(cacheSchema, ...)` produced, tagged with 
`SPARK::time::precision` by `toPrecisionTaggedArrowField`. So the column's type 
on the way out is the Spark type recovered from the round-tripped Arrow field, 
which is the guard you asked for. `cachedVectors` now reports that type and 
each case asserts it is `TimeType(precision)`; the tautological assertion is 
gone.
   
   I checked that it bites rather than assuming it: with `fromArrowField` 
mutated to read a key that is not there, the TIME case fails with 
`Set(TimeType(6)) did not equal Set(TimeType(0))` - the canonical-microsecond 
fallback - at precisions 0, 3 and 9, while `checkAnswer` still passes, which is 
exactly your point that the values alone cannot carry this.
   
   The "Why" is reworded to match: the precision is reconstructed from the 
cache schema on read rather than persisted in the cached bytes, and what the 
new assertion pins is the tag-and-recover path that reconstruction goes 
through. Thank you for reading it that closely.
   



##########
sql/core/src/test/scala/org/apache/spark/sql/execution/columnar/ArrowCachedBatchSerializerSuite.scala:
##########
@@ -1243,6 +1243,85 @@ class ArrowCachedBatchSerializerSuite extends QueryTest 
with SharedSparkSession
 
   // Helper: cache a single-column DataFrame (row path) and return its 
ArrowCachedBatch stats.
   // Stats layout per column: [lowerBound(0), upperBound(1), nullCount(2), 
rowCount(3), size(4)].
+  // Helper: the InMemoryRelation behind a cached DataFrame, after populating 
the cache. The
+  // DataFrame's queryExecution is memoized, so this must run before anything 
else forces it.
+  private def cachedRelation(df: org.apache.spark.sql.DataFrame): 
InMemoryRelation = {
+    df.cache()
+    df.count()
+    df.queryExecution.executedPlan.collectFirst {
+      case scan: InMemoryTableScanExec => scan.relation
+    }.get
+  }
+
+  // Helper: (Arrow vector class, row count, null count) per cached batch of a 
single-column
+  // relation, read back through the serializer's own columnar path.
+  private def cachedVectors(relation: InMemoryRelation): Array[(String, Int, 
Int)] = {
+    val attrs = relation.output
+    relation.cacheBuilder.serializer
+      .convertCachedBatchToColumnarBatch(
+        relation.cacheBuilder.cachedColumnBuffers, attrs, attrs, 
spark.sessionState.conf)
+      .mapPartitions { batches =>
+        batches.map { batch =>
+          val column = batch.column(0).asInstanceOf[ArrowColumnVector]
+          (column.getValueVector.getClass.getSimpleName, batch.numRows(), 
column.numNulls())
+        }
+      }
+      .collect()
+  }
+
+  private val nullPatterns: Seq[(String, Int => Boolean)] = Seq(
+    ("every 31st row null", i => i % 31 == 0),
+    ("no nulls", _ => false),
+    ("all nulls", _ => true))
+
+  test("SPARK-59571: TIME keeps its precision and its nulls through the cache, 
at every " +
+      "precision") {
+    // Every precision of TIME is written to the same TimeNanoVector; the 
precision travels in
+    // the Arrow field metadata and comes back through the cached relation's 
schema, so the
+    // cached type is checked as well as the values. The values are truncated 
to the declared
+    // precision so a precision loss could not hide behind a value the type 
would round anyway.
+    val rows = 1000
+    val nanosPerDay = 86400000000000L
+    for (precision <- Seq(0, 3, 6, 9); (pattern, isNull) <- nullPatterns) {
+      val unit = math.pow(10, 9 - precision).toLong
+      def timeAt(i: Int): LocalTime = {
+        val nanos = ((i.toLong * nanosPerDay) / rows + i.toLong * 1234567L) % 
nanosPerDay
+        LocalTime.ofNanoOfDay(nanos / unit * unit)
+      }
+      val values = (0 until rows).map(i => if (isNull(i)) null else timeAt(i))
+      val df = singlePartDf(values, TimeType(precision))
+      val relation = cachedRelation(df)
+      checkAnswer(df, values.map(Row(_)))
+      assert(relation.output.head.dataType === TimeType(precision),
+        s"TIME($precision), $pattern: the cached relation must keep the 
precision")
+      val vectors = cachedVectors(relation)
+      assert(vectors.map(_._1).toSet === Set("TimeNanoVector"), 
s"TIME($precision), $pattern")
+      assert(vectors.map(_._2).sum === rows, s"TIME($precision), $pattern")
+      assert(vectors.map(_._3).sum === (0 until rows).count(isNull),
+        s"TIME($precision), $pattern: null count")
+      df.unpersist()
+    }
+  }
+
+  test("SPARK-59571: day-time intervals of both signs keep their microseconds 
through the " +
+      "cache") {
+    val rows = 1000
+    for ((pattern, isNull) <- nullPatterns) {
+      // Whole microseconds, negative for the first half of the rows and 
positive for the rest.
+      def intervalAt(i: Int): Duration = Duration.ofNanos((i.toLong - rows / 
2) * 7001000000L)
+      val values = (0 until rows).map(i => if (isNull(i)) null else 
intervalAt(i))
+      val df = singlePartDf(values, DayTimeIntervalType())
+      val relation = cachedRelation(df)
+      checkAnswer(df, values.map(Row(_)))
+      assert(relation.output.head.dataType === DayTimeIntervalType(), pattern)
+      val vectors = cachedVectors(relation)
+      assert(vectors.map(_._1).toSet === Set("DurationVector"), pattern)
+      assert(vectors.map(_._2).sum === rows, pattern)
+      assert(vectors.map(_._3).sum === (0 until rows).count(isNull), 
s"$pattern: null count")
+      df.unpersist()
+    }
+  }
+

Review Comment:
   Correct, and that one is straightforwardly my mistake: the block went in 
between the `cachedStats` doc comment and `cachedStats` itself, so the comment 
ended up describing `cachedRelation`, which returns an `InMemoryRelation` and 
no stats at all. `cachedRelation`, `cachedVectors`, `nullPatterns` and the two 
tests now sit after `singlePartDf`, which puts `cachedStats` back under its own 
comment and keeps the new helpers next to the helper they build on.
   



-- 
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