This is an automated email from the ASF dual-hosted git repository.

zhouyuan pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/gluten.git


The following commit(s) were added to refs/heads/main by this push:
     new 321229acd2 [GLUTEN-12538][VL] Unblock TIMESTAMP_NTZ min/max in Delta 
statistics (#12967)
321229acd2 is described below

commit 321229acd2a4d9551190583ddfb02d7827df268e
Author: Felipe Pessoto <[email protected]>
AuthorDate: Tue Sep 29 01:26:46 2026 -0700

    [GLUTEN-12538][VL] Unblock TIMESTAMP_NTZ min/max in Delta statistics 
(#12967)
---
 .../util/delta-spark-ut/known-failures.txt         |  42 -----
 .../delta/stats/GlutenDeltaJobStatsTracker.scala   |  23 ++-
 .../spark/sql/delta/GlutenDeltaStatsSuite.scala    |  91 ++++++++++
 .../delta/stats/GlutenDeltaJobStatsTracker.scala   |  23 ++-
 .../spark/sql/delta/GlutenDeltaStatsSuite.scala    |  91 ++++++++++
 .../org/apache/gluten/config/VeloxConfig.scala     |   7 +-
 cpp/velox/substrait/VeloxSubstraitSignature.cc     |   7 +
 cpp/velox/substrait/VeloxToSubstraitType.cc        |   8 +
 cpp/velox/tests/VeloxSubstraitRoundTripTest.cc     |  32 ++++
 cpp/velox/tests/VeloxSubstraitSignatureTest.cc     |   2 +
 cpp/velox/tests/VeloxToSubstraitTypeTest.cc        |   9 +
 docs/velox-configuration.md                        |   2 +-
 .../HashAggregateExecBaseTransformer.scala         |   2 +-
 .../apache/gluten/expression/ConverterUtils.scala  |   5 +-
 .../extension/columnar/validator/Validators.scala  |  19 ++-
 .../gluten/utils/velox/VeloxTestSettings.scala     |   1 +
 .../sql/GlutenTimestampNtzAggregateSuite.scala     | 187 +++++++++++++++++++++
 17 files changed, 494 insertions(+), 57 deletions(-)

diff --git a/.github/workflows/util/delta-spark-ut/known-failures.txt 
b/.github/workflows/util/delta-spark-ut/known-failures.txt
index faaec020ab..2d3d457d49 100644
--- a/.github/workflows/util/delta-spark-ut/known-failures.txt
+++ b/.github/workflows/util/delta-spark-ut/known-failures.txt
@@ -637,78 +637,36 @@ 
org.apache.spark.sql.delta.stats.DataSkippingDeltaV1JsonCheckpointV2Suite#Data s
 org.apache.spark.sql.delta.stats.DataSkippingDeltaV1JsonCheckpointV2Suite#Data 
skipping handles aliasing for _metadata fields - old behavior with DataFrame 
schema
 org.apache.spark.sql.delta.stats.DataSkippingDeltaV1JsonCheckpointV2Suite#data 
skipping flags
 org.apache.spark.sql.delta.stats.DataSkippingDeltaV1JsonCheckpointV2Suite#data 
skipping flags - old behavior with DataFrame schema
-org.apache.spark.sql.delta.stats.DataSkippingDeltaV1JsonCheckpointV2Suite#data 
skipping on TIMESTAMP_NTZ
-org.apache.spark.sql.delta.stats.DataSkippingDeltaV1JsonCheckpointV2Suite#data 
skipping on TIMESTAMP_NTZ - old behavior with DataFrame schema
-org.apache.spark.sql.delta.stats.DataSkippingDeltaV1JsonCheckpointV2Suite#data 
skipping on TIMESTAMP_NTZ near Long.MaxValue
-org.apache.spark.sql.delta.stats.DataSkippingDeltaV1JsonCheckpointV2Suite#data 
skipping on TIMESTAMP_NTZ near Long.MaxValue - old behavior with DataFrame 
schema
-org.apache.spark.sql.delta.stats.DataSkippingDeltaV1JsonCheckpointV2Suite#data 
skipping on TIMESTAMP_NTZ with Long.MaxValue
-org.apache.spark.sql.delta.stats.DataSkippingDeltaV1JsonCheckpointV2Suite#data 
skipping on TIMESTAMP_NTZ with Long.MaxValue - old behavior with DataFrame 
schema
 org.apache.spark.sql.delta.stats.DataSkippingDeltaV1JsonCheckpointV2Suite#data 
skipping stats before and after optimize
 org.apache.spark.sql.delta.stats.DataSkippingDeltaV1JsonCheckpointV2Suite#data 
skipping stats before and after optimize - old behavior with DataFrame schema
 
org.apache.spark.sql.delta.stats.DataSkippingDeltaV1NameColumnMappingSuite#data 
skipping flags - column mapping name mode
 
org.apache.spark.sql.delta.stats.DataSkippingDeltaV1NameColumnMappingSuite#data 
skipping flags - column mapping name mode - old behavior with DataFrame schema
-org.apache.spark.sql.delta.stats.DataSkippingDeltaV1NameColumnMappingSuite#data
 skipping on TIMESTAMP_NTZ - column mapping name mode
-org.apache.spark.sql.delta.stats.DataSkippingDeltaV1NameColumnMappingSuite#data
 skipping on TIMESTAMP_NTZ - column mapping name mode - old behavior with 
DataFrame schema
-org.apache.spark.sql.delta.stats.DataSkippingDeltaV1NameColumnMappingSuite#data
 skipping on TIMESTAMP_NTZ near Long.MaxValue - column mapping name mode
-org.apache.spark.sql.delta.stats.DataSkippingDeltaV1NameColumnMappingSuite#data
 skipping on TIMESTAMP_NTZ near Long.MaxValue - column mapping name mode - old 
behavior with DataFrame schema
-org.apache.spark.sql.delta.stats.DataSkippingDeltaV1NameColumnMappingSuite#data
 skipping on TIMESTAMP_NTZ with Long.MaxValue - column mapping name mode
-org.apache.spark.sql.delta.stats.DataSkippingDeltaV1NameColumnMappingSuite#data
 skipping on TIMESTAMP_NTZ with Long.MaxValue - column mapping name mode - old 
behavior with DataFrame schema
 
org.apache.spark.sql.delta.stats.DataSkippingDeltaV1NameColumnMappingSuite#data 
skipping stats before and after optimize - column mapping name mode
 
org.apache.spark.sql.delta.stats.DataSkippingDeltaV1NameColumnMappingSuite#data 
skipping stats before and after optimize - column mapping name mode - old 
behavior with DataFrame schema
 
org.apache.spark.sql.delta.stats.DataSkippingDeltaV1ParquetCheckpointV2Suite#Data
 skipping handles aliasing for _metadata fields
 
org.apache.spark.sql.delta.stats.DataSkippingDeltaV1ParquetCheckpointV2Suite#Data
 skipping handles aliasing for _metadata fields - old behavior with DataFrame 
schema
 
org.apache.spark.sql.delta.stats.DataSkippingDeltaV1ParquetCheckpointV2Suite#data
 skipping flags
 
org.apache.spark.sql.delta.stats.DataSkippingDeltaV1ParquetCheckpointV2Suite#data
 skipping flags - old behavior with DataFrame schema
-org.apache.spark.sql.delta.stats.DataSkippingDeltaV1ParquetCheckpointV2Suite#data
 skipping on TIMESTAMP_NTZ
-org.apache.spark.sql.delta.stats.DataSkippingDeltaV1ParquetCheckpointV2Suite#data
 skipping on TIMESTAMP_NTZ - old behavior with DataFrame schema
-org.apache.spark.sql.delta.stats.DataSkippingDeltaV1ParquetCheckpointV2Suite#data
 skipping on TIMESTAMP_NTZ near Long.MaxValue
-org.apache.spark.sql.delta.stats.DataSkippingDeltaV1ParquetCheckpointV2Suite#data
 skipping on TIMESTAMP_NTZ near Long.MaxValue - old behavior with DataFrame 
schema
-org.apache.spark.sql.delta.stats.DataSkippingDeltaV1ParquetCheckpointV2Suite#data
 skipping on TIMESTAMP_NTZ with Long.MaxValue
-org.apache.spark.sql.delta.stats.DataSkippingDeltaV1ParquetCheckpointV2Suite#data
 skipping on TIMESTAMP_NTZ with Long.MaxValue - old behavior with DataFrame 
schema
 
org.apache.spark.sql.delta.stats.DataSkippingDeltaV1ParquetCheckpointV2Suite#data
 skipping stats before and after optimize
 
org.apache.spark.sql.delta.stats.DataSkippingDeltaV1ParquetCheckpointV2Suite#data
 skipping stats before and after optimize - old behavior with DataFrame schema
 org.apache.spark.sql.delta.stats.DataSkippingDeltaV1Suite#Data skipping 
handles aliasing for _metadata fields
 org.apache.spark.sql.delta.stats.DataSkippingDeltaV1Suite#Data skipping 
handles aliasing for _metadata fields - old behavior with DataFrame schema
 org.apache.spark.sql.delta.stats.DataSkippingDeltaV1Suite#data skipping flags
 org.apache.spark.sql.delta.stats.DataSkippingDeltaV1Suite#data skipping flags 
- old behavior with DataFrame schema
-org.apache.spark.sql.delta.stats.DataSkippingDeltaV1Suite#data skipping on 
TIMESTAMP_NTZ
-org.apache.spark.sql.delta.stats.DataSkippingDeltaV1Suite#data skipping on 
TIMESTAMP_NTZ - old behavior with DataFrame schema
-org.apache.spark.sql.delta.stats.DataSkippingDeltaV1Suite#data skipping on 
TIMESTAMP_NTZ near Long.MaxValue
-org.apache.spark.sql.delta.stats.DataSkippingDeltaV1Suite#data skipping on 
TIMESTAMP_NTZ near Long.MaxValue - old behavior with DataFrame schema
-org.apache.spark.sql.delta.stats.DataSkippingDeltaV1Suite#data skipping on 
TIMESTAMP_NTZ with Long.MaxValue
-org.apache.spark.sql.delta.stats.DataSkippingDeltaV1Suite#data skipping on 
TIMESTAMP_NTZ with Long.MaxValue - old behavior with DataFrame schema
 org.apache.spark.sql.delta.stats.DataSkippingDeltaV1Suite#data skipping stats 
before and after optimize
 org.apache.spark.sql.delta.stats.DataSkippingDeltaV1Suite#data skipping stats 
before and after optimize - old behavior with DataFrame schema
 
org.apache.spark.sql.delta.stats.DataSkippingDeltaV1WithCatalogOwnedBatch100Suite#Data
 skipping handles aliasing for _metadata fields
 
org.apache.spark.sql.delta.stats.DataSkippingDeltaV1WithCatalogOwnedBatch100Suite#Data
 skipping handles aliasing for _metadata fields - old behavior with DataFrame 
schema
 
org.apache.spark.sql.delta.stats.DataSkippingDeltaV1WithCatalogOwnedBatch100Suite#data
 skipping flags
 
org.apache.spark.sql.delta.stats.DataSkippingDeltaV1WithCatalogOwnedBatch100Suite#data
 skipping flags - old behavior with DataFrame schema
-org.apache.spark.sql.delta.stats.DataSkippingDeltaV1WithCatalogOwnedBatch100Suite#data
 skipping on TIMESTAMP_NTZ
-org.apache.spark.sql.delta.stats.DataSkippingDeltaV1WithCatalogOwnedBatch100Suite#data
 skipping on TIMESTAMP_NTZ - old behavior with DataFrame schema
-org.apache.spark.sql.delta.stats.DataSkippingDeltaV1WithCatalogOwnedBatch100Suite#data
 skipping on TIMESTAMP_NTZ near Long.MaxValue
-org.apache.spark.sql.delta.stats.DataSkippingDeltaV1WithCatalogOwnedBatch100Suite#data
 skipping on TIMESTAMP_NTZ near Long.MaxValue - old behavior with DataFrame 
schema
-org.apache.spark.sql.delta.stats.DataSkippingDeltaV1WithCatalogOwnedBatch100Suite#data
 skipping on TIMESTAMP_NTZ with Long.MaxValue
-org.apache.spark.sql.delta.stats.DataSkippingDeltaV1WithCatalogOwnedBatch100Suite#data
 skipping on TIMESTAMP_NTZ with Long.MaxValue - old behavior with DataFrame 
schema
 
org.apache.spark.sql.delta.stats.DataSkippingDeltaV1WithCatalogOwnedBatch1Suite#Data
 skipping handles aliasing for _metadata fields
 
org.apache.spark.sql.delta.stats.DataSkippingDeltaV1WithCatalogOwnedBatch1Suite#Data
 skipping handles aliasing for _metadata fields - old behavior with DataFrame 
schema
 
org.apache.spark.sql.delta.stats.DataSkippingDeltaV1WithCatalogOwnedBatch1Suite#data
 skipping flags
 
org.apache.spark.sql.delta.stats.DataSkippingDeltaV1WithCatalogOwnedBatch1Suite#data
 skipping flags - old behavior with DataFrame schema
-org.apache.spark.sql.delta.stats.DataSkippingDeltaV1WithCatalogOwnedBatch1Suite#data
 skipping on TIMESTAMP_NTZ
-org.apache.spark.sql.delta.stats.DataSkippingDeltaV1WithCatalogOwnedBatch1Suite#data
 skipping on TIMESTAMP_NTZ - old behavior with DataFrame schema
-org.apache.spark.sql.delta.stats.DataSkippingDeltaV1WithCatalogOwnedBatch1Suite#data
 skipping on TIMESTAMP_NTZ near Long.MaxValue
-org.apache.spark.sql.delta.stats.DataSkippingDeltaV1WithCatalogOwnedBatch1Suite#data
 skipping on TIMESTAMP_NTZ near Long.MaxValue - old behavior with DataFrame 
schema
-org.apache.spark.sql.delta.stats.DataSkippingDeltaV1WithCatalogOwnedBatch1Suite#data
 skipping on TIMESTAMP_NTZ with Long.MaxValue
-org.apache.spark.sql.delta.stats.DataSkippingDeltaV1WithCatalogOwnedBatch1Suite#data
 skipping on TIMESTAMP_NTZ with Long.MaxValue - old behavior with DataFrame 
schema
 
org.apache.spark.sql.delta.stats.DataSkippingDeltaV1WithCatalogOwnedBatch2Suite#Data
 skipping handles aliasing for _metadata fields
 
org.apache.spark.sql.delta.stats.DataSkippingDeltaV1WithCatalogOwnedBatch2Suite#Data
 skipping handles aliasing for _metadata fields - old behavior with DataFrame 
schema
 
org.apache.spark.sql.delta.stats.DataSkippingDeltaV1WithCatalogOwnedBatch2Suite#data
 skipping flags
 
org.apache.spark.sql.delta.stats.DataSkippingDeltaV1WithCatalogOwnedBatch2Suite#data
 skipping flags - old behavior with DataFrame schema
-org.apache.spark.sql.delta.stats.DataSkippingDeltaV1WithCatalogOwnedBatch2Suite#data
 skipping on TIMESTAMP_NTZ
-org.apache.spark.sql.delta.stats.DataSkippingDeltaV1WithCatalogOwnedBatch2Suite#data
 skipping on TIMESTAMP_NTZ - old behavior with DataFrame schema
-org.apache.spark.sql.delta.stats.DataSkippingDeltaV1WithCatalogOwnedBatch2Suite#data
 skipping on TIMESTAMP_NTZ near Long.MaxValue
-org.apache.spark.sql.delta.stats.DataSkippingDeltaV1WithCatalogOwnedBatch2Suite#data
 skipping on TIMESTAMP_NTZ near Long.MaxValue - old behavior with DataFrame 
schema
-org.apache.spark.sql.delta.stats.DataSkippingDeltaV1WithCatalogOwnedBatch2Suite#data
 skipping on TIMESTAMP_NTZ with Long.MaxValue
-org.apache.spark.sql.delta.stats.DataSkippingDeltaV1WithCatalogOwnedBatch2Suite#data
 skipping on TIMESTAMP_NTZ with Long.MaxValue - old behavior with DataFrame 
schema
 
org.apache.spark.sql.delta.stats.PartitionLikeDataSkippingColumnMappingSuite#partition-like
 data skipping for expression COALESCE: COALESCE(TO_DATE(S.b), c) = 
'1976-07-03' - column mapping id mode
 org.apache.spark.sql.delta.stats.StatsCollectionSuite#recompute stats multiple 
columns and files
 org.apache.spark.sql.delta.typewidening.TypeWideningAlterTableSuite#type 
widening DATE -> TIMESTAMP_NTZ, partitioned=false
diff --git 
a/backends-velox/src-delta33/main/scala/org/apache/spark/sql/delta/stats/GlutenDeltaJobStatsTracker.scala
 
b/backends-velox/src-delta33/main/scala/org/apache/spark/sql/delta/stats/GlutenDeltaJobStatsTracker.scala
index 2ea7b9e554..dd4baa5b3a 100644
--- 
a/backends-velox/src-delta33/main/scala/org/apache/spark/sql/delta/stats/GlutenDeltaJobStatsTracker.scala
+++ 
b/backends-velox/src-delta33/main/scala/org/apache/spark/sql/delta/stats/GlutenDeltaJobStatsTracker.scala
@@ -39,12 +39,12 @@ import org.apache.spark.sql.catalyst.InternalRow
 import org.apache.spark.sql.catalyst.expressions.{Attribute, EmptyRow, 
Expression, Projection, SortOrder, SpecificInternalRow, UnsafeProjection}
 import 
org.apache.spark.sql.catalyst.expressions.aggregate.{AggregateExpression, 
Complete, DeclarativeAggregate}
 import 
org.apache.spark.sql.catalyst.expressions.codegen.GenerateMutableProjection
-import org.apache.spark.sql.execution.{ColumnarCollapseTransformStages, 
LeafExecNode, ProjectExec}
+import org.apache.spark.sql.execution.{ColumnarCollapseTransformStages, 
LeafExecNode, ProjectExec, SparkPlan}
 import org.apache.spark.sql.execution.aggregate.SortAggregateExec
 import org.apache.spark.sql.execution.datasources.{BasicWriteJobStatsTracker, 
WriteJobStatsTracker, WriteTaskStats, WriteTaskStatsTracker}
 import org.apache.spark.sql.execution.metric.SQLMetric
 import org.apache.spark.sql.vectorized.ColumnarBatch
-import org.apache.spark.util.{SerializableConfiguration, SparkDirectoryUtil}
+import org.apache.spark.util.{SerializableConfiguration, SparkDirectoryUtil, 
Utils}
 
 import com.google.common.collect.Lists
 import org.apache.hadoop.conf.Configuration
@@ -86,6 +86,22 @@ private[stats] class GlutenDeltaJobStatsTracker(val 
delegate: DeltaJobStatistics
 }
 
 object GlutenDeltaJobStatsTracker extends Logging {
+  private val statsPlanObserverLock = new Object
+  @volatile private var statsPlanObserver: Option[(Path, SparkPlan) => Unit] = 
None
+
+  /** Observes task-local statistics plans in local-mode tests; callbacks run 
on task threads. */
+  private[delta] def withStatsPlanObserver[T](observer: (Path, SparkPlan) => 
Unit)(f: => T): T =
+    statsPlanObserverLock.synchronized {
+      require(Utils.isTesting, "Statistics plan observation is only available 
in tests")
+      require(statsPlanObserver.isEmpty, "A statistics plan observer is 
already registered")
+      statsPlanObserver = Some(observer)
+      try {
+        f
+      } finally {
+        statsPlanObserver = None
+      }
+    }
+
   def apply(tracker: WriteJobStatsTracker): WriteJobStatsTracker = tracker 
match {
     case tracker: BasicWriteJobStatsTracker =>
       new GlutenDeltaJobStatsRowCountingTracker(tracker)
@@ -171,6 +187,9 @@ object GlutenDeltaJobStatsTracker extends Logging {
         .asInstanceOf[WholeStageTransformer]
         .child
         .asInstanceOf[TransformSupport]
+      if (Utils.isTesting) {
+        statsPlanObserver.foreach(_(rootPath, wholeStageTransformer))
+      }
       val substraitContext = new SubstraitContext
       TransformerState.enterValidation
       val transformedNode =
diff --git 
a/backends-velox/src-delta33/test/scala/org/apache/spark/sql/delta/GlutenDeltaStatsSuite.scala
 
b/backends-velox/src-delta33/test/scala/org/apache/spark/sql/delta/GlutenDeltaStatsSuite.scala
new file mode 100644
index 0000000000..c2013c113f
--- /dev/null
+++ 
b/backends-velox/src-delta33/test/scala/org/apache/spark/sql/delta/GlutenDeltaStatsSuite.scala
@@ -0,0 +1,91 @@
+/*
+ * 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.spark.sql.delta
+
+import org.apache.gluten.execution.HashAggregateExecTransformer
+
+import org.apache.spark.sql.Row
+import org.apache.spark.sql.delta.sources.DeltaSQLConf
+import org.apache.spark.sql.delta.stats.GlutenDeltaJobStatsTracker
+import org.apache.spark.sql.delta.test.DeltaSQLCommandTest
+import org.apache.spark.sql.execution.SparkPlan
+
+import java.util.concurrent.ConcurrentLinkedQueue
+
+import scala.collection.JavaConverters._
+
+class GlutenDeltaStatsSuite extends DeltaSQLCommandTest {
+
+  import testImplicits._
+
+  test("collect TIMESTAMP_NTZ statistics natively") {
+    withSQLConf(DeltaSQLConf.DELTA_COLLECT_STATS.key -> "true") {
+      withTempDir {
+        dir =>
+          val path = dir.getCanonicalPath
+          val data = Seq(
+            "1969-12-31 23:59:59.999999",
+            "2024-01-01 00:00:00.123456"
+          ).toDF("input")
+            .selectExpr(
+              "cast(input as timestamp_ntz) as ts",
+              "struct(cast(input as timestamp_ntz) as ts) as nested")
+
+          val statsPlans = new ConcurrentLinkedQueue[SparkPlan]()
+          GlutenDeltaJobStatsTracker.withStatsPlanObserver {
+            (statsPath, plan) =>
+              if (statsPath.toUri.getPath == path) {
+                statsPlans.add(plan)
+              }
+          } {
+            data.coalesce(1).write.format("delta").save(path)
+          }
+          assert(!statsPlans.isEmpty, "The write did not use the native Delta 
statistics tracker")
+          statsPlans.asScala.foreach {
+            plan =>
+              assert(
+                plan.exists(_.isInstanceOf[HashAggregateExecTransformer]),
+                s"Expected a native statistics aggregate, but 
got:\n${plan.treeString}")
+          }
+
+          val actual = spark.read.format("delta").load(path)
+          assert(actual.collect().toSet == data.collect().toSet)
+
+          val addFiles = DeltaLog.forTable(spark, 
path).update().allFiles.collect()
+          assert(addFiles.length == 1)
+          val stats = addFiles.head.stats
+          assert(stats != null)
+          val statsValues = Seq(stats)
+            .toDF("stats")
+            .selectExpr(
+              "get_json_object(stats, '$.minValues.ts')",
+              "get_json_object(stats, '$.minValues.nested.ts')",
+              "get_json_object(stats, '$.maxValues.ts')",
+              "get_json_object(stats, '$.maxValues.nested.ts')"
+            )
+            .head()
+          assert(
+            statsValues == Row(
+              "1969-12-31T23:59:59.999",
+              "1969-12-31T23:59:59.999",
+              "2024-01-01T00:00:00.123",
+              "2024-01-01T00:00:00.123"),
+            stats)
+      }
+    }
+  }
+}
diff --git 
a/backends-velox/src-delta40/main/scala/org/apache/spark/sql/delta/stats/GlutenDeltaJobStatsTracker.scala
 
b/backends-velox/src-delta40/main/scala/org/apache/spark/sql/delta/stats/GlutenDeltaJobStatsTracker.scala
index ca6c7a6a7f..7b0d901dd7 100644
--- 
a/backends-velox/src-delta40/main/scala/org/apache/spark/sql/delta/stats/GlutenDeltaJobStatsTracker.scala
+++ 
b/backends-velox/src-delta40/main/scala/org/apache/spark/sql/delta/stats/GlutenDeltaJobStatsTracker.scala
@@ -39,12 +39,12 @@ import org.apache.spark.sql.catalyst.InternalRow
 import org.apache.spark.sql.catalyst.expressions.{Attribute, BindReferences, 
EmptyRow, Expression, RuntimeReplaceable, SortOrder, SpecificInternalRow}
 import 
org.apache.spark.sql.catalyst.expressions.aggregate.{AggregateExpression, 
Complete, DeclarativeAggregate}
 import 
org.apache.spark.sql.catalyst.expressions.codegen.GenerateMutableProjection
-import org.apache.spark.sql.execution.{ColumnarCollapseTransformStages, 
LeafExecNode, ProjectExec}
+import org.apache.spark.sql.execution.{ColumnarCollapseTransformStages, 
LeafExecNode, ProjectExec, SparkPlan}
 import org.apache.spark.sql.execution.aggregate.SortAggregateExec
 import org.apache.spark.sql.execution.datasources.{BasicWriteJobStatsTracker, 
WriteJobStatsTracker, WriteTaskStats, WriteTaskStatsTracker}
 import org.apache.spark.sql.execution.metric.SQLMetric
 import org.apache.spark.sql.vectorized.ColumnarBatch
-import org.apache.spark.util.{SerializableConfiguration, SparkDirectoryUtil}
+import org.apache.spark.util.{SerializableConfiguration, SparkDirectoryUtil, 
Utils}
 
 import com.google.common.collect.Lists
 import org.apache.hadoop.conf.Configuration
@@ -86,6 +86,22 @@ private[stats] class GlutenDeltaJobStatsTracker(val 
delegate: DeltaJobStatistics
 }
 
 object GlutenDeltaJobStatsTracker extends Logging {
+  private val statsPlanObserverLock = new Object
+  @volatile private var statsPlanObserver: Option[(Path, SparkPlan) => Unit] = 
None
+
+  /** Observes task-local statistics plans in local-mode tests; callbacks run 
on task threads. */
+  private[delta] def withStatsPlanObserver[T](observer: (Path, SparkPlan) => 
Unit)(f: => T): T =
+    statsPlanObserverLock.synchronized {
+      require(Utils.isTesting, "Statistics plan observation is only available 
in tests")
+      require(statsPlanObserver.isEmpty, "A statistics plan observer is 
already registered")
+      statsPlanObserver = Some(observer)
+      try {
+        f
+      } finally {
+        statsPlanObserver = None
+      }
+    }
+
   def apply(tracker: WriteJobStatsTracker): WriteJobStatsTracker = tracker 
match {
     case tracker: BasicWriteJobStatsTracker =>
       new GlutenDeltaJobStatsRowCountingTracker(tracker)
@@ -175,6 +191,9 @@ object GlutenDeltaJobStatsTracker extends Logging {
         .asInstanceOf[WholeStageTransformer]
         .child
         .asInstanceOf[TransformSupport]
+      if (Utils.isTesting) {
+        statsPlanObserver.foreach(_(rootPath, wholeStageTransformer))
+      }
       val substraitContext = new SubstraitContext
       TransformerState.enterValidation
       val transformedNode =
diff --git 
a/backends-velox/src-delta40/test/scala/org/apache/spark/sql/delta/GlutenDeltaStatsSuite.scala
 
b/backends-velox/src-delta40/test/scala/org/apache/spark/sql/delta/GlutenDeltaStatsSuite.scala
new file mode 100644
index 0000000000..c2013c113f
--- /dev/null
+++ 
b/backends-velox/src-delta40/test/scala/org/apache/spark/sql/delta/GlutenDeltaStatsSuite.scala
@@ -0,0 +1,91 @@
+/*
+ * 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.spark.sql.delta
+
+import org.apache.gluten.execution.HashAggregateExecTransformer
+
+import org.apache.spark.sql.Row
+import org.apache.spark.sql.delta.sources.DeltaSQLConf
+import org.apache.spark.sql.delta.stats.GlutenDeltaJobStatsTracker
+import org.apache.spark.sql.delta.test.DeltaSQLCommandTest
+import org.apache.spark.sql.execution.SparkPlan
+
+import java.util.concurrent.ConcurrentLinkedQueue
+
+import scala.collection.JavaConverters._
+
+class GlutenDeltaStatsSuite extends DeltaSQLCommandTest {
+
+  import testImplicits._
+
+  test("collect TIMESTAMP_NTZ statistics natively") {
+    withSQLConf(DeltaSQLConf.DELTA_COLLECT_STATS.key -> "true") {
+      withTempDir {
+        dir =>
+          val path = dir.getCanonicalPath
+          val data = Seq(
+            "1969-12-31 23:59:59.999999",
+            "2024-01-01 00:00:00.123456"
+          ).toDF("input")
+            .selectExpr(
+              "cast(input as timestamp_ntz) as ts",
+              "struct(cast(input as timestamp_ntz) as ts) as nested")
+
+          val statsPlans = new ConcurrentLinkedQueue[SparkPlan]()
+          GlutenDeltaJobStatsTracker.withStatsPlanObserver {
+            (statsPath, plan) =>
+              if (statsPath.toUri.getPath == path) {
+                statsPlans.add(plan)
+              }
+          } {
+            data.coalesce(1).write.format("delta").save(path)
+          }
+          assert(!statsPlans.isEmpty, "The write did not use the native Delta 
statistics tracker")
+          statsPlans.asScala.foreach {
+            plan =>
+              assert(
+                plan.exists(_.isInstanceOf[HashAggregateExecTransformer]),
+                s"Expected a native statistics aggregate, but 
got:\n${plan.treeString}")
+          }
+
+          val actual = spark.read.format("delta").load(path)
+          assert(actual.collect().toSet == data.collect().toSet)
+
+          val addFiles = DeltaLog.forTable(spark, 
path).update().allFiles.collect()
+          assert(addFiles.length == 1)
+          val stats = addFiles.head.stats
+          assert(stats != null)
+          val statsValues = Seq(stats)
+            .toDF("stats")
+            .selectExpr(
+              "get_json_object(stats, '$.minValues.ts')",
+              "get_json_object(stats, '$.minValues.nested.ts')",
+              "get_json_object(stats, '$.maxValues.ts')",
+              "get_json_object(stats, '$.maxValues.nested.ts')"
+            )
+            .head()
+          assert(
+            statsValues == Row(
+              "1969-12-31T23:59:59.999",
+              "1969-12-31T23:59:59.999",
+              "2024-01-01T00:00:00.123",
+              "2024-01-01T00:00:00.123"),
+            stats)
+      }
+    }
+  }
+}
diff --git 
a/backends-velox/src/main/scala/org/apache/gluten/config/VeloxConfig.scala 
b/backends-velox/src/main/scala/org/apache/gluten/config/VeloxConfig.scala
index eadd105e8c..ed2217f252 100644
--- a/backends-velox/src/main/scala/org/apache/gluten/config/VeloxConfig.scala
+++ b/backends-velox/src/main/scala/org/apache/gluten/config/VeloxConfig.scala
@@ -1017,9 +1017,10 @@ object VeloxConfig extends ConfigRegistry {
   val ENABLE_TIMESTAMP_NTZ_VALIDATION =
     
buildConf("spark.gluten.sql.columnar.backend.velox.enableTimestampNtzValidation")
       .doc(
-        "Enable validation fallback for TimestampNTZ type. When true, any plan 
" +
-          "containing TimestampNTZ will fall back to Spark execution. When 
false, " +
-          "allows native execution for TimestampNTZ scan.")
+        "Enable validation fallback for TimestampNTZ type. When true, plans 
with " +
+          "TimestampNTZ in their input or output schemas fall back to Spark 
execution. " +
+          "When false, supported TimestampNTZ operations are eligible for 
native execution, " +
+          "subject to other validation rules.")
       .booleanConf
       .createWithDefault(false)
 
diff --git a/cpp/velox/substrait/VeloxSubstraitSignature.cc 
b/cpp/velox/substrait/VeloxSubstraitSignature.cc
index 2dd01e8c72..b140d42c80 100644
--- a/cpp/velox/substrait/VeloxSubstraitSignature.cc
+++ b/cpp/velox/substrait/VeloxSubstraitSignature.cc
@@ -24,6 +24,9 @@ std::string 
VeloxSubstraitSignature::toSubstraitSignature(const TypePtr& type) {
   if (type->isDate()) {
     return "date";
   }
+  if (type->equivalent(*TIMESTAMP_UTC())) {
+    return "tsntz";
+  }
 
   switch (type->kind()) {
     case TypeKind::BOOLEAN:
@@ -155,6 +158,10 @@ TypePtr 
VeloxSubstraitSignature::fromSubstraitSignature(const std::string& signa
     return TIMESTAMP();
   }
 
+  if (signature == "tsntz") {
+    return TIMESTAMP_UTC();
+  }
+
   if (signature == "date") {
     return DATE();
   }
diff --git a/cpp/velox/substrait/VeloxToSubstraitType.cc 
b/cpp/velox/substrait/VeloxToSubstraitType.cc
index c3f2d4b44d..90dbc58c2b 100644
--- a/cpp/velox/substrait/VeloxToSubstraitType.cc
+++ b/cpp/velox/substrait/VeloxToSubstraitType.cc
@@ -31,6 +31,14 @@ const ::substrait::Type& 
VeloxToSubstraitTypeConvertor::toSubstraitType(
     substraitType->set_allocated_date(substraitDate);
     return *substraitType;
   }
+  if (type->equivalent(*velox::TIMESTAMP_UTC())) {
+    auto substraitPrecisionTimestamp =
+        
google::protobuf::Arena::CreateMessage<::substrait::Type_PrecisionTimestamp>(&arena);
+    substraitPrecisionTimestamp->set_precision(6);
+    
substraitPrecisionTimestamp->set_nullability(::substrait::Type_Nullability_NULLABILITY_NULLABLE);
+    
substraitType->set_allocated_precision_timestamp(substraitPrecisionTimestamp);
+    return *substraitType;
+  }
 
   switch (type->kind()) {
     case velox::TypeKind::BOOLEAN: {
diff --git a/cpp/velox/tests/VeloxSubstraitRoundTripTest.cc 
b/cpp/velox/tests/VeloxSubstraitRoundTripTest.cc
index 1950a249ef..6a9b2447d0 100644
--- a/cpp/velox/tests/VeloxSubstraitRoundTripTest.cc
+++ b/cpp/velox/tests/VeloxSubstraitRoundTripTest.cc
@@ -23,6 +23,7 @@
 #include "velox/common/base/tests/GTestUtils.h"
 #include "velox/exec/tests/utils/OperatorTestBase.h"
 #include "velox/exec/tests/utils/PlanBuilder.h"
+#include "velox/functions/sparksql/aggregates/Register.h"
 #include "velox/vector/tests/utils/VectorMaker.h"
 
 #include "substrait/SubstraitToVeloxPlan.h"
@@ -205,6 +206,36 @@ TEST_F(VeloxSubstraitRoundTripTest, countAll) {
   assertPlanConversion(plan, "SELECT count(*) as num_price FROM tmp WHERE c6 < 
24 GROUP BY c0, c1");
 }
 
+TEST_F(VeloxSubstraitRoundTripTest, minMaxTimestampUtc) {
+  const auto minTimestamp = Timestamp(-1, 999'999'000);
+  const auto maxTimestamp = Timestamp(1'704'067'200, 123'456'000);
+  auto input = makeRowVector({makeFlatVector<Timestamp>({maxTimestamp, 
minTimestamp}, TIMESTAMP_UTC())});
+  auto expected = makeRowVector(
+      {makeFlatVector<Timestamp>({minTimestamp}, TIMESTAMP_UTC()),
+       makeFlatVector<Timestamp>({maxTimestamp}, TIMESTAMP_UTC())});
+  auto plan = PlanBuilder()
+                  .values({input})
+                  .singleAggregation({}, {"spark_min(c0)", "spark_max(c0)"})
+                  .project({"a0", "a1"})
+                  .planNode();
+
+  assertQuery(plan, expected);
+
+  google::protobuf::Arena arena;
+  auto substraitPlan = veloxConvertor_->toSubstrait(arena, plan);
+  auto config = 
std::make_shared<facebook::velox::config::ConfigBase>(std::unordered_map<std::string,
 std::string>());
+  auto converter = std::make_shared<SubstraitToVeloxPlanConverter>(
+      pool_.get(),
+      config.get(),
+      std::vector<std::shared_ptr<ResultIterator>>{},
+      VeloxConnectorIds{},
+      std::nullopt,
+      std::nullopt,
+      true);
+
+  assertQuery(converter->toVeloxPlan(substraitPlan), expected);
+}
+
 TEST_F(VeloxSubstraitRoundTripTest, sum) {
   GTEST_SKIP(); // Only partial step and single step of aggregation is 
currently supported.
   auto vectors = makeVectors(2, 7, 3);
@@ -565,6 +596,7 @@ TEST_F(VeloxSubstraitRoundTripTest, avgCompanion) {
 
 int main(int argc, char** argv) {
   gluten::registerAllFunctions();
+  
facebook::velox::functions::aggregate::sparksql::registerAggregateFunctions("spark_");
   testing::InitGoogleTest(&argc, argv);
   folly::init(&argc, &argv, false);
   return RUN_ALL_TESTS();
diff --git a/cpp/velox/tests/VeloxSubstraitSignatureTest.cc 
b/cpp/velox/tests/VeloxSubstraitSignatureTest.cc
index cb62f97649..ff128617b4 100644
--- a/cpp/velox/tests/VeloxSubstraitSignatureTest.cc
+++ b/cpp/velox/tests/VeloxSubstraitSignatureTest.cc
@@ -55,6 +55,7 @@ TEST_F(VeloxSubstraitSignatureTest, 
toSubstraitSignatureWithType) {
   ASSERT_EQ(toSubstraitSignature(VARCHAR()), "str");
   ASSERT_EQ(toSubstraitSignature(VARBINARY()), "vbin");
   ASSERT_EQ(toSubstraitSignature(TIMESTAMP()), "ts");
+  ASSERT_EQ(toSubstraitSignature(TIMESTAMP_UTC()), "tsntz");
   ASSERT_EQ(toSubstraitSignature(DATE()), "date");
   ASSERT_EQ(toSubstraitSignature(ARRAY(BOOLEAN())), "list");
   ASSERT_EQ(toSubstraitSignature(ARRAY(INTEGER())), "list");
@@ -106,6 +107,7 @@ TEST_F(VeloxSubstraitSignatureTest, fromSubstraitSignature) 
{
   ASSERT_EQ(fromSubstraitSignature("str")->kind(), TypeKind::VARCHAR);
   ASSERT_EQ(fromSubstraitSignature("vbin")->kind(), TypeKind::VARBINARY);
   ASSERT_EQ(fromSubstraitSignature("ts")->kind(), TypeKind::TIMESTAMP);
+  ASSERT_TRUE(fromSubstraitSignature("tsntz")->equivalent(*TIMESTAMP_UTC()));
   ASSERT_EQ(fromSubstraitSignature("date")->kind(), TypeKind::INTEGER);
   ASSERT_EQ(fromSubstraitSignature("dec<18,2>")->kind(), TypeKind::BIGINT);
   ASSERT_EQ(fromSubstraitSignature("dec<19,2>")->kind(), TypeKind::HUGEINT);
diff --git a/cpp/velox/tests/VeloxToSubstraitTypeTest.cc 
b/cpp/velox/tests/VeloxToSubstraitTypeTest.cc
index e7d637ddbb..9f47770be9 100644
--- a/cpp/velox/tests/VeloxToSubstraitTypeTest.cc
+++ b/cpp/velox/tests/VeloxToSubstraitTypeTest.cc
@@ -62,4 +62,13 @@ TEST_F(VeloxToSubstraitTypeTest, basic) {
   testTypeConversion(ROW({}, {}));
 }
 
+TEST_F(VeloxToSubstraitTypeTest, timestampUtc) {
+  google::protobuf::Arena arena;
+  const auto& substraitType = typeConvertor_->toSubstraitType(arena, 
TIMESTAMP_UTC());
+
+  ASSERT_TRUE(substraitType.has_precision_timestamp());
+  ASSERT_EQ(substraitType.precision_timestamp().precision(), 6);
+  
ASSERT_TRUE(SubstraitParser::parseType(substraitType)->equivalent(*TIMESTAMP_UTC()));
+}
+
 } // namespace gluten
diff --git a/docs/velox-configuration.md b/docs/velox-configuration.md
index 82b7848bc8..86b1b116eb 100644
--- a/docs/velox-configuration.md
+++ b/docs/velox-configuration.md
@@ -30,7 +30,7 @@ nav_order: 16
 | spark.gluten.sql.columnar.backend.velox.cudf.shuffleMaxPrefetchBytes         
    | 🔄 Dynamic    | 1028MB            | Maximum bytes to prefetch in CPU 
memory during GPU shuffle read while waiting for GPU available.                 
                                                                                
                                                                                
                                                                                
                    [...]
 | spark.gluten.sql.columnar.backend.velox.directorySizeGuess                   
    | âš“ Static      | 32KB              | Deprecated, rename to 
spark.gluten.sql.columnar.backend.velox.footerEstimatedSize                     
                                                                                
                                                                                
                                                                                
                              [...]
 | spark.gluten.sql.columnar.backend.velox.driverSideBroadcastHashTableBuild    
    | 🔄 Dynamic    | false             | Enable driver-side broadcast hash 
table build. When enabled, the hash table is built and serialized on the 
driver, then broadcast to executors. When disabled, each executor builds its 
own hash table from the broadcast data.                                         
                                                                                
                             [...]
-| spark.gluten.sql.columnar.backend.velox.enableTimestampNtzValidation         
    | 🔄 Dynamic    | false             | Enable validation fallback for 
TimestampNTZ type. When true, any plan containing TimestampNTZ will fall back 
to Spark execution. When false, allows native execution for TimestampNTZ scan.  
                                                                                
                                                                                
                        [...]
+| spark.gluten.sql.columnar.backend.velox.enableTimestampNtzValidation         
    | 🔄 Dynamic    | false             | Enable validation fallback for 
TimestampNTZ type. When true, plans with TimestampNTZ in their input or output 
schemas fall back to Spark execution. When false, supported TimestampNTZ 
operations are eligible for native execution, subject to other validation 
rules.                                                                          
                                    [...]
 | spark.gluten.sql.columnar.backend.velox.fileHandleCacheEnabled               
    | âš“ Static      | true              | Enables caching of open file handles 
to avoid repeated open/close overhead. Benefits both local filesystems (fewer 
open/close syscalls and file descriptor churn) and remote filesystems/object 
stores (reused connection state). Should be disabled if files are mutable, i.e. 
file content may change while the file path stays the same.                     
                    [...]
 | spark.gluten.sql.columnar.backend.velox.fileHandleExpirationDurationMs       
    | âš“ Static      | 10m               | Expiration time for cached file 
handles. Handles not accessed within this duration are evicted from the cache. 
This prevents stale handles from accumulating (e.g., expired HDFS leases, 
closed remote connections). Accepts a Spark duration string (e.g., "10m", 
"600s") or a plain number interpreted as milliseconds. A value of 0 disables 
TTL-based eviction.                 [...]
 | spark.gluten.sql.columnar.backend.velox.filePreloadThreshold                 
    | âš“ Static      | 1MB               | Set the file preload threshold for 
velox file scan, refer to Velox's file-preload-threshold                        
                                                                                
                                                                                
                                                                                
                 [...]
diff --git 
a/gluten-substrait/src/main/scala/org/apache/gluten/execution/HashAggregateExecBaseTransformer.scala
 
b/gluten-substrait/src/main/scala/org/apache/gluten/execution/HashAggregateExecBaseTransformer.scala
index f4e174d9f5..0e4c7c1a6e 100644
--- 
a/gluten-substrait/src/main/scala/org/apache/gluten/execution/HashAggregateExecBaseTransformer.scala
+++ 
b/gluten-substrait/src/main/scala/org/apache/gluten/execution/HashAggregateExecBaseTransformer.scala
@@ -98,7 +98,7 @@ abstract class HashAggregateExecBaseTransformer(
 
   protected def checkType(dataType: DataType): Boolean = {
     dataType match {
-      case BooleanType | StringType | TimestampType | DateType | BinaryType =>
+      case BooleanType | StringType | TimestampType | TimestampNTZType | 
DateType | BinaryType =>
         true
       case _: NumericType => true
       case _: ArrayType => true
diff --git 
a/gluten-substrait/src/main/scala/org/apache/gluten/expression/ConverterUtils.scala
 
b/gluten-substrait/src/main/scala/org/apache/gluten/expression/ConverterUtils.scala
index 4c2528b588..be5522721d 100644
--- 
a/gluten-substrait/src/main/scala/org/apache/gluten/expression/ConverterUtils.scala
+++ 
b/gluten-substrait/src/main/scala/org/apache/gluten/expression/ConverterUtils.scala
@@ -239,7 +239,7 @@ object ConverterUtils extends Logging {
         TypeBuilder.makeDecimal(nullable, precision, scale)
       case TimestampType =>
         TypeBuilder.makeTimestamp(nullable)
-      case other if other.typeName == "timestamp_ntz" =>
+      case TimestampNTZType =>
         TypeBuilder.makeTimestampNTZ(nullable)
       case m: MapType =>
         TypeBuilder.makeMap(
@@ -414,7 +414,8 @@ object ConverterUtils extends Logging {
       case DoubleType => "fp64"
       case DateType => "date"
       case TimestampType => "ts"
-      case other if other.typeName == "timestamp_ntz" => "ts_ntz"
+      // Underscores delimit arguments in native function signatures.
+      case TimestampNTZType => "tsntz"
       case StringType => "str"
       case BinaryType => "vbin"
       case DecimalType() =>
diff --git 
a/gluten-substrait/src/main/scala/org/apache/gluten/extension/columnar/validator/Validators.scala
 
b/gluten-substrait/src/main/scala/org/apache/gluten/extension/columnar/validator/Validators.scala
index 6c648efaba..a12bda1ce3 100644
--- 
a/gluten-substrait/src/main/scala/org/apache/gluten/extension/columnar/validator/Validators.scala
+++ 
b/gluten-substrait/src/main/scala/org/apache/gluten/extension/columnar/validator/Validators.scala
@@ -25,7 +25,7 @@ import 
org.apache.gluten.extension.columnar.offload.OffloadSingleNode
 import org.apache.gluten.sql.shims.SparkShimLoader
 
 import org.apache.spark.internal.Logging
-import org.apache.spark.sql.catalyst.expressions.{Cast, ConvertTimezone, Hour, 
Minute, Second, TimestampAdd}
+import org.apache.spark.sql.catalyst.expressions.{Alias, Attribute, Cast, 
ConvertTimezone, Expression, GetStructField, Hour, IsNotNull, IsNull, Minute, 
Second, TimestampAdd}
 import org.apache.spark.sql.execution._
 import org.apache.spark.sql.execution.aggregate.{HashAggregateExec, 
ObjectHashAggregateExec, SortAggregateExec}
 import org.apache.spark.sql.execution.datasources.WriteFilesExec
@@ -34,7 +34,7 @@ import 
org.apache.spark.sql.execution.exchange.{BroadcastExchangeExec, ShuffleEx
 import org.apache.spark.sql.execution.joins._
 import org.apache.spark.sql.execution.window.WindowExec
 import org.apache.spark.sql.hive.HiveTableScanExecTransformer
-import org.apache.spark.sql.types.{ArrayType, DataType, MapType, StructType}
+import org.apache.spark.sql.types.{ArrayType, DataType, MapType, StructType, 
TimestampNTZType}
 
 object Validators {
   implicit class ValidatorBuilderImplicits(builder: Validator.Builder) {
@@ -251,13 +251,19 @@ object Validators {
 
     override def validate(plan: SparkPlan): Validator.OutCome = {
       def containsNTZ(dataType: DataType): Boolean = dataType match {
-        case dt if dt.typeName == "timestamp_ntz" => true
+        case TimestampNTZType => true
         case st: StructType => st.exists(f => containsNTZ(f.dataType))
         case at: ArrayType => containsNTZ(at.elementType)
         case mt: MapType => containsNTZ(mt.keyType) || 
containsNTZ(mt.valueType)
         case _ => false
       }
-      def isNTZ(dataType: DataType): Boolean = dataType.typeName == 
"timestamp_ntz"
+      def isNTZ(dataType: DataType): Boolean = dataType == TimestampNTZType
+      def isDirectNtzProjection(expression: Expression): Boolean = expression 
match {
+        case alias: Alias => isDirectNtzProjection(alias.child)
+        case attribute: Attribute => containsNTZ(attribute.dataType)
+        case field: GetStructField => containsNTZ(field.dataType)
+        case _ => false
+      }
       val hasNTZ = plan.output.exists(a => containsNTZ(a.dataType)) ||
         plan.children.exists(_.output.exists(a => containsNTZ(a.dataType)))
       if (!hasNTZ) {
@@ -273,11 +279,14 @@ object Validators {
           case _ => false
         }
         val isSupportedNtz = plan match {
+          case _: HashAggregateExec | _: ObjectHashAggregateExec | _: 
SortAggregateExec => true
+          case _: ShuffleExchangeExec => true
           case p: ProjectExec =>
             p.projectList.forall {
               expr =>
                 (!containsNTZ(expr.dataType) &&
                   !expr.references.exists(a => containsNTZ(a.dataType))) ||
+                isDirectNtzProjection(expr) ||
                 expr.exists {
                   case Hour(child, _) => containsNTZ(child.dataType)
                   case Minute(child, _) => containsNTZ(child.dataType)
@@ -285,6 +294,8 @@ object Validators {
                   case TimestampAdd(_, _, child, _) => 
containsNTZ(child.dataType)
                   case ConvertTimezone(_, _, child) => 
containsNTZ(child.dataType)
                   case c: Cast if isNTZ(c.dataType) || isNTZ(c.child.dataType) 
=> true
+                  case IsNull(child) => containsNTZ(child.dataType)
+                  case IsNotNull(child) => containsNTZ(child.dataType)
                   case _ => false
                 }
             }
diff --git 
a/gluten-ut/spark41/src/test/scala/org/apache/gluten/utils/velox/VeloxTestSettings.scala
 
b/gluten-ut/spark41/src/test/scala/org/apache/gluten/utils/velox/VeloxTestSettings.scala
index 0abaabf465..b9110d896c 100644
--- 
a/gluten-ut/spark41/src/test/scala/org/apache/gluten/utils/velox/VeloxTestSettings.scala
+++ 
b/gluten-ut/spark41/src/test/scala/org/apache/gluten/utils/velox/VeloxTestSettings.scala
@@ -1151,6 +1151,7 @@ class VeloxTestSettings extends BackendTestSettings {
     .excludeByPrefix("SPARK-26893")
     .exclude("SPARK-36280: Remove redundant aliases after 
RewritePredicateSubquery")
     .exclude("SPARK-43402: FileSourceScanExec supports push down data filter 
with scalar subquery")
+  enableSuite[GlutenTimestampNtzAggregateSuite]
   enableSuite[GlutenTypedImperativeAggregateSuite]
   enableSuite[GlutenUnwrapCastInComparisonEndToEndSuite]
   enableSuite[GlutenUnsafeRowChecksumSuite]
diff --git 
a/gluten-ut/spark41/src/test/scala/org/apache/spark/sql/GlutenTimestampNtzAggregateSuite.scala
 
b/gluten-ut/spark41/src/test/scala/org/apache/spark/sql/GlutenTimestampNtzAggregateSuite.scala
new file mode 100644
index 0000000000..f189f2ddac
--- /dev/null
+++ 
b/gluten-ut/spark41/src/test/scala/org/apache/spark/sql/GlutenTimestampNtzAggregateSuite.scala
@@ -0,0 +1,187 @@
+/*
+ * 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.spark.sql
+
+import org.apache.gluten.config.GlutenConfig
+import org.apache.gluten.execution.{HashAggregateExecBaseTransformer, 
ProjectExecTransformer}
+
+import org.apache.spark.sql.execution.ProjectExec
+import org.apache.spark.sql.execution.aggregate.BaseAggregateExec
+import org.apache.spark.sql.functions.{max, min}
+import org.apache.spark.sql.internal.SQLConf
+import org.apache.spark.sql.types.TimestampNTZType
+
+import java.time.LocalDateTime
+
+class GlutenTimestampNtzAggregateSuite extends GlutenSQLTestsTrait {
+
+  import testImplicits._
+
+  testGluten("min and max") {
+    withSQLConf(
+      SQLConf.ANSI_ENABLED.key -> "false",
+      GlutenConfig.GLUTEN_ANSI_FALLBACK_ENABLED.key -> "false") {
+      withTempPath {
+        path =>
+          Seq(
+            "1969-12-31 23:59:59.999999",
+            "2024-01-01 00:00:00.123456"
+          ).toDF("input")
+            .selectExpr("cast(input as timestamp_ntz) as ts")
+            .write
+            .parquet(path.getCanonicalPath)
+
+          val result = 
spark.read.parquet(path.getCanonicalPath).agg(min($"ts"), max($"ts"))
+          checkAnswer(
+            result,
+            Row(
+              LocalDateTime.parse("1969-12-31T23:59:59.999999"),
+              LocalDateTime.parse("2024-01-01T00:00:00.123456")))
+          assert(
+            
getExecutedPlan(result).exists(_.isInstanceOf[HashAggregateExecBaseTransformer]),
+            result.queryExecution.executedPlan.treeString)
+      }
+    }
+  }
+
+  testGluten("min and max grouped by timestamp_ntz") {
+    withSQLConf(
+      SQLConf.ANSI_ENABLED.key -> "false",
+      SQLConf.ADAPTIVE_EXECUTION_ENABLED.key -> "false",
+      SQLConf.SESSION_LOCAL_TIMEZONE.key -> "America/Los_Angeles",
+      SQLConf.SHUFFLE_PARTITIONS.key -> "2",
+      GlutenConfig.GLUTEN_ANSI_FALLBACK_ENABLED.key -> "false",
+      "spark.gluten.sql.columnar.backend.velox.enableTimestampNtzValidation" 
-> "false"
+    ) {
+      withTempPath {
+        path =>
+          val beforeEpoch = LocalDateTime.parse("1969-12-31T23:59:59.999999")
+          val afterEpoch = LocalDateTime.parse("1970-01-01T00:00:00.000001")
+          val firstKey = LocalDateTime.parse("2024-01-01T00:00:00.123456")
+          val secondKey = LocalDateTime.parse("2024-01-01T00:00:00.123457")
+          Seq(
+            (firstKey, beforeEpoch),
+            (firstKey, secondKey),
+            (firstKey, null),
+            (secondKey, afterEpoch),
+            (secondKey, firstKey),
+            (null, beforeEpoch),
+            (null, afterEpoch),
+            (beforeEpoch, null)
+          ).toDF("key", "ts")
+            .write
+            .parquet(path.getCanonicalPath)
+
+          val result = spark.read
+            .parquet(path.getCanonicalPath)
+            .groupBy($"key")
+            .agg(min($"ts"), max($"ts"))
+          checkAnswer(
+            result,
+            Seq(
+              Row(firstKey, beforeEpoch, secondKey),
+              Row(secondKey, afterEpoch, firstKey),
+              Row(null, beforeEpoch, afterEpoch),
+              Row(beforeEpoch, null, null)))
+          val aggregates = getExecutedPlan(result).collect {
+            case aggregate: BaseAggregateExec => aggregate
+          }
+          assert(aggregates.nonEmpty, 
result.queryExecution.executedPlan.treeString)
+          assert(
+            aggregates.forall {
+              case aggregate: HashAggregateExecBaseTransformer =>
+                aggregate.groupingExpressions.map(_.dataType) == 
Seq(TimestampNTZType)
+              case _ => false
+            },
+            result.queryExecution.executedPlan.treeString
+          )
+      }
+    }
+  }
+
+  testGluten("null predicate projections respect timestamp_ntz validation") {
+    withSQLConf(
+      SQLConf.ANSI_ENABLED.key -> "false",
+      SQLConf.SESSION_LOCAL_TIMEZONE.key -> "America/Los_Angeles",
+      GlutenConfig.GLUTEN_ANSI_FALLBACK_ENABLED.key -> "false") {
+      withTempPath {
+        path =>
+          Seq(
+            LocalDateTime.parse("1969-12-31T23:59:59.999999"),
+            LocalDateTime.parse("1970-01-01T00:00:00"),
+            LocalDateTime.parse("2024-01-01T00:00:00.123456"),
+            null
+          ).toDF("ts").write.parquet(path.getCanonicalPath)
+
+          Seq(false, true).foreach {
+            enableValidation =>
+              withSQLConf(
+                
"spark.gluten.sql.columnar.backend.velox.enableTimestampNtzValidation" ->
+                  enableValidation.toString) {
+                val result = spark.read
+                  .parquet(path.getCanonicalPath)
+                  .selectExpr("isnull(ts)", "isnotnull(ts)", 
"if(isnotnull(ts), 1, 0)")
+                checkAnswer(
+                  result,
+                  Seq(
+                    Row(false, true, 1),
+                    Row(false, true, 1),
+                    Row(false, true, 1),
+                    Row(true, false, 0)))
+                val resultOutput = result.queryExecution.executedPlan.outputSet
+                val hasNativeProject = getExecutedPlan(result).exists {
+                  case project: ProjectExecTransformer => project.outputSet == 
resultOutput
+                  case _ => false
+                }
+                assert(
+                  hasNativeProject == !enableValidation,
+                  result.queryExecution.executedPlan.treeString)
+              }
+          }
+      }
+    }
+  }
+
+  testGluten("unsupported project falls back") {
+    withSQLConf(
+      SQLConf.ANSI_ENABLED.key -> "false",
+      SQLConf.SESSION_LOCAL_TIMEZONE.key -> "America/Los_Angeles",
+      GlutenConfig.GLUTEN_ANSI_FALLBACK_ENABLED.key -> "false") {
+      withTempPath {
+        path =>
+          Seq("2024-01-01 00:00:00.123456")
+            .toDF("input")
+            .selectExpr("cast(input as timestamp_ntz) as ts")
+            .write
+            .parquet(path.getCanonicalPath)
+
+          val result = spark.read
+            .parquet(path.getCanonicalPath)
+            .selectExpr("to_json(named_struct('ts', ts))")
+          checkAnswer(result, Row("""{"ts":"2024-01-01T00:00:00.123"}"""))
+          val resultOutput = result.queryExecution.executedPlan.outputSet
+          val hasFallbackProject = getExecutedPlan(result).exists {
+            case project: ProjectExec => project.outputSet == resultOutput
+            case _ => false
+          }
+          assert(
+            hasFallbackProject,
+            result.queryExecution.executedPlan.treeString)
+      }
+    }
+  }
+}


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

Reply via email to