andygrove commented on code in PR #5638:
URL: https://github.com/apache/datafusion-comet/pull/5638#discussion_r3927032089


##########
native/spark-expr/src/iceberg_funcs/temporal.rs:
##########
@@ -0,0 +1,339 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+//! Iceberg's `years`, `months`, `days`, and `hours` transforms.
+//!
+//! Iceberg's `DateTimeUtil` evaluates all four in UTC regardless of the Spark 
session timezone
+//! (`TimestampType` and `TimestampNTZType` are handled identically), and all 
four floor: a value
+//! before the epoch maps to a negative period. `years` and `months` are 
calendar-aware, `days` and
+//! `hours` are plain floor division of the epoch value. `days` returns a date 
(Iceberg's
+//! `DaysFunction.resultType()` is `DateType`), the other three return an int.
+//!
+//! The kernels work on the raw epoch values rather than going through Arrow's 
timezone-aware

Review Comment:
   _LLM-assisted: this reply and the changes it describes were written with 
Claude Code._
   
   Agreed on both counts, and the module docs now say so (71e03be): Comet tags 
`TimestampType` `UTC` and the writer casts to a schema that tags `Timestamptz` 
`+00:00`, so `date_part` would agree today; the epoch arithmetic is correct for 
any tag. I know of no plan shape where a different tag reaches these kernels.
   
   I kept the local kernels rather than delegating, and added a test that makes 
the reason executable: `iceberg_rust_years_follow_the_timezone_tag` in 
`iceberg_write.rs` shows `Transform::Year` returning 0 for `-1` micros tagged 
`Asia/Kathmandu` where Iceberg's Java `DateTimeUtil` and Comet both return -1. 
If that test ever fails, iceberg-rust has dropped the tag dependency and 
delegating becomes safe.



##########
spark/src/test/scala/org/apache/comet/CometIcebergSystemFunctionSuite.scala:
##########
@@ -0,0 +1,400 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.apache.comet
+
+import java.io.File
+import java.math.{BigDecimal => JBigDecimal, BigInteger}
+import java.nio.file.Files
+import java.time.{Instant, LocalDate, LocalDateTime, ZoneOffset}
+
+import scala.util.Random
+
+import org.scalactic.source.Position
+import org.scalatest.Tag
+
+import org.apache.spark.SparkConf
+import org.apache.spark.sql.{CometTestBase, DataFrame, Row}
+import org.apache.spark.sql.catalyst.expressions.{AttributeReference, 
Expression, Literal}
+import org.apache.spark.sql.catalyst.expressions.objects.StaticInvoke
+import org.apache.spark.sql.comet.{CometIcebergWriteExec, CometSortExec}
+import org.apache.spark.sql.comet.execution.shuffle.{CometNativeShuffle, 
CometShuffleExchangeExec}
+import org.apache.spark.sql.execution.adaptive.AdaptiveSparkPlanHelper
+import org.apache.spark.sql.execution.exchange.ShuffleExchangeExec
+import org.apache.spark.sql.internal.SQLConf
+import org.apache.spark.sql.types._
+
+import org.apache.comet.serde.{CometExpressionSerde, CometIcebergBucket, 
CometIcebergTruncate, CometStaticInvoke, Compatible, SupportLevel, Unsupported}
+
+/**
+ * Native support for Iceberg's system functions (`bucket`, `truncate`, 
`years`, `months`, `days`,
+ * `hours`).
+ *
+ * Every comparison runs the same query with Comet on and off, so the 
reference values come from
+ * Iceberg's own JVM implementations (`BucketFunction`, `TruncateFunction`, 
...) evaluated by
+ * Spark, over seeded random data plus the boundary values of each type. A 
native result that
+ * disagreed with Iceberg would only fail loudly on the write path (the 
clustered writer rejects
+ * out-of-order partitions); in a filter or projection it would be a silently 
wrong answer, which
+ * is why the coverage is per type rather than a few hand-picked rows.
+ */
+class CometIcebergSystemFunctionSuite
+    extends CometTestBase
+    with CometIcebergTestBase
+    with AdaptiveSparkPlanHelper {
+
+  override protected def sparkConf: SparkConf = {
+    super.sparkConf
+      .set(CometConf.COMET_ICEBERG_WRITE_SPLIT_OPERATOR_ENABLED.key, "true")
+      .set(CometConf.COMET_ICEBERG_NATIVE_WRITE_ENABLED.key, "true")
+  }
+
+  override protected def test(testName: String, testTags: Tag*)(testFun: => 
Any)(implicit
+      pos: Position): Unit = {
+    super.test(testName, testTags: _*) {
+      assume(icebergAvailable, "Iceberg not available in classpath")
+      testFun
+    }
+  }
+
+  private val catalog = "ice"
+  private val source = "system_function_source"
+  private val bucketColumns =
+    Seq("i8", "i16", "i32", "i64", "dec18", "dec38", "str", "bin", "dt", "ts", 
"ts_ntz")
+  private val truncateColumns = Seq("i8", "i16", "i32", "i64", "dec18", 
"dec38", "str", "bin")
+
+  // The source data is written once per suite; every test reads the same 
parquet directory.
+  private var sourceDir: File = _
+  private def sourcePath: String = new File(sourceDir, "data").getAbsolutePath
+
+  override def beforeAll(): Unit = {
+    super.beforeAll()
+    sourceDir = 
Files.createTempDirectory("comet-iceberg-system-functions").toFile
+    sourceData().write.parquet(sourcePath)
+  }
+
+  override def afterAll(): Unit = {
+    try deleteRecursively(sourceDir)
+    finally super.afterAll()
+  }
+
+  test("bucket matches Iceberg for every supported type") {
+    withSourceTable {
+      bucketColumns.foreach { column =>
+        val buckets =
+          Seq(1, 7, 16, Int.MaxValue).map(n => s"$catalog.system.bucket($n, 
$column)")
+        checkSparkAnswerAndOperator(s"SELECT $column, ${buckets.mkString(", 
")} FROM $source")
+      }
+    }
+  }
+
+  test("truncate matches Iceberg for every supported type") {
+    withSourceTable {
+      truncateColumns.foreach { column =>
+        val truncated =
+          Seq(1, 3, 10, 1000, Int.MaxValue).map(w => 
s"$catalog.system.truncate($w, $column)")
+        checkSparkAnswerAndOperator(s"SELECT $column, ${truncated.mkString(", 
")} FROM $source")
+      }
+    }
+  }
+
+  test("years, months, days, and hours match Iceberg regardless of session 
timezone") {
+    withSourceTable {
+      // Iceberg evaluates the temporal transforms in UTC; a shifted session 
timezone must not
+      // leak into the native result either.
+      for (timezone <- Seq("UTC", "America/Los_Angeles", "Asia/Kathmandu")) {
+        withSQLConf(SQLConf.SESSION_LOCAL_TIMEZONE.key -> timezone) {
+          Seq("dt", "ts", "ts_ntz").foreach { column =>
+            val functions = Seq("years", "months", "days") ++ (if (column == 
"dt") Nil
+                                                               else 
Seq("hours"))
+            val transformed = functions.map(f => 
s"$catalog.system.$f($column)")
+            checkSparkAnswerAndOperator(
+              s"SELECT $column, ${transformed.mkString(", ")} FROM $source")
+          }
+        }
+      }
+    }
+  }
+
+  test("system functions in filters stay native") {
+    withSourceTable {
+      checkSparkAnswerAndOperator(
+        s"SELECT i32 FROM $source WHERE $catalog.system.bucket(8, i32) IN (0, 
3)")
+      checkSparkAnswerAndOperator(
+        s"SELECT str FROM $source WHERE $catalog.system.truncate(1, str) = 
'a'")
+      checkSparkAnswerAndOperator(
+        s"SELECT ts FROM $source WHERE $catalog.system.days(ts) >= DATE 
'2000-01-01'")
+      checkSparkAnswerAndOperator(s"SELECT dt FROM $source WHERE 
$catalog.system.months(dt) < 0")
+    }
+  }
+
+  test("sorting on system functions stays native") {
+    withSourceTable {
+      val df = sql(
+        s"SELECT i32, str FROM $source " +
+          s"ORDER BY $catalog.system.bucket(4, i32), 
$catalog.system.truncate(2, str), i32, str")
+      checkSparkAnswerAndOperator(df)
+      val sorts = collect(stripAQEPlan(df.queryExecution.executedPlan)) { case 
s: CometSortExec =>
+        s
+      }
+      assert(sorts.nonEmpty, "expected a native sort")
+    }
+  }
+
+  test("hash partitioning on system functions uses the native shuffle") {
+    withSourceTable {
+      val df = sql(
+        s"SELECT i32, str, ts FROM $source " +
+          s"DISTRIBUTE BY $catalog.system.bucket(8, i32), 
$catalog.system.truncate(2, str), " +
+          s"$catalog.system.hours(ts)")
+      checkSparkAnswerAndOperator(df)
+      checkCometExchange(df, 1, native = true)
+    }
+  }
+
+  test("partitioned Iceberg write with default distribution mode stays native 
end to end") {
+    withSourceTable {
+      val table = s"$catalog.db.hidden_partitioning"
+      // No `write.distribution-mode`: Iceberg picks hash distribution for a 
partitioned table,
+      // which plans a shuffle and a local sort on the partition transforms. 
The string column is
+      // deliberately not a partition source: its multi-byte values would land 
in partition
+      // directory names, which iceberg-java reads back through the JVM's 
platform charset.
+      sql(s"""
+        CREATE TABLE $table (i32 INT, i64 BIGINT, ts TIMESTAMP, dt DATE)
+        USING iceberg
+        PARTITIONED BY (bucket(4, i32), truncate(1000, i64), days(ts), 
months(dt))""")
+      // iceberg-rust's own truncate transform, which the writer uses for 
partition values, does

Review Comment:
   _LLM-assisted: this reply and the changes it describes were written with 
Claude Code._
   
   Fair — the writer-side bug should not be buried in a test comment. I will 
file it against iceberg-rust separately and link it here rather than hold this 
PR on it. It is a bit wider than `truncate_i64`: `truncate_i32` uses 
`rem_euclid`, which diverges from Java for widths above 2^30 as well as 
overflowing at `Integer.MIN_VALUE`, and the decimal kernel has the same 
unchecked subtraction. In the meantime the new `iceberg_rust_transform_parity` 
module in `iceberg_write.rs` records the excluded inputs in one place, with the 
reason, instead of only here.



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