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

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


The following commit(s) were added to refs/heads/main by this push:
     new 65b3ef316a Spark 4.2: Add scanDuration read metric (#18751)
65b3ef316a is described below

commit 65b3ef316ae0c6fb13a9150b5eaac1258f8cda47
Author: Venkata krishnan Sowrirajan <[email protected]>
AuthorDate: Mon Nov 2 05:57:03 2026 -0800

    Spark 4.2: Add scanDuration read metric (#18751)
    
    Port of #17562 to Spark 4.2. Reports how long each task spends reading its
    splits as the scanDuration custom metric, in nanoseconds.
    
    Generated-by: Claude Opus 5.5
    
    Co-authored-by: Venkata Krishnan Sowrirajan <[email protected]>
---
 .../apache/iceberg/spark/source/BaseReader.java    | 10 +++
 .../iceberg/spark/source/BatchDataReader.java      |  5 +-
 .../apache/iceberg/spark/source/RowDataReader.java |  5 +-
 .../org/apache/iceberg/spark/source/SparkScan.java |  2 +
 .../iceberg/spark/source/metrics/ScanDuration.java | 78 ++++++++++++++++++++++
 .../spark/source/metrics/TaskScanDuration.java     | 40 +++++++++++
 .../iceberg/spark/source/TestSparkReadMetrics.java | 65 ++++++++++++++++++
 .../spark/source/metrics/TestScanDuration.java     | 75 +++++++++++++++++++++
 8 files changed, 278 insertions(+), 2 deletions(-)

diff --git 
a/spark/v4.2/spark/src/main/java/org/apache/iceberg/spark/source/BaseReader.java
 
b/spark/v4.2/spark/src/main/java/org/apache/iceberg/spark/source/BaseReader.java
index a4d9766ae7..50dc69dd9a 100644
--- 
a/spark/v4.2/spark/src/main/java/org/apache/iceberg/spark/source/BaseReader.java
+++ 
b/spark/v4.2/spark/src/main/java/org/apache/iceberg/spark/source/BaseReader.java
@@ -77,6 +77,8 @@ abstract class BaseReader<T, TaskT extends ScanTask> 
implements Closeable {
   private final Iterator<TaskT> tasks;
   private final DeleteCounter counter;
   private final boolean cacheDeleteFilesOnExecutors;
+  // nanos, not millis: a single split can be read in well under a millisecond
+  private long scanDurationNanos = 0L;
 
   private Map<String, InputFile> lazyInputFiles;
   private CloseableIterator<T> currentIterator;
@@ -132,7 +134,13 @@ abstract class BaseReader<T, TaskT extends ScanTask> 
implements Closeable {
     return counter;
   }
 
+  /** Wall time spent reading in this task, in nanoseconds. */
+  protected long scanDurationNanos() {
+    return scanDurationNanos;
+  }
+
   public boolean next() throws IOException {
+    long start = System.nanoTime();
     try {
       while (true) {
         if (currentIterator.hasNext()) {
@@ -156,6 +164,8 @@ abstract class BaseReader<T, TaskT extends ScanTask> 
implements Closeable {
         LOG.error("Error reading file(s): {}", filePaths, e);
       }
       throw e;
+    } finally {
+      this.scanDurationNanos += System.nanoTime() - start;
     }
   }
 
diff --git 
a/spark/v4.2/spark/src/main/java/org/apache/iceberg/spark/source/BatchDataReader.java
 
b/spark/v4.2/spark/src/main/java/org/apache/iceberg/spark/source/BatchDataReader.java
index 237dfd5c69..d6ef3bb095 100644
--- 
a/spark/v4.2/spark/src/main/java/org/apache/iceberg/spark/source/BatchDataReader.java
+++ 
b/spark/v4.2/spark/src/main/java/org/apache/iceberg/spark/source/BatchDataReader.java
@@ -33,6 +33,7 @@ import org.apache.iceberg.spark.OrcBatchReadConf;
 import org.apache.iceberg.spark.ParquetBatchReadConf;
 import org.apache.iceberg.spark.source.metrics.TaskNumDeletes;
 import org.apache.iceberg.spark.source.metrics.TaskNumSplits;
+import org.apache.iceberg.spark.source.metrics.TaskScanDuration;
 import org.apache.spark.rdd.InputFileBlockHolder;
 import org.apache.spark.sql.connector.metric.CustomTaskMetric;
 import org.apache.spark.sql.connector.read.PartitionReader;
@@ -88,7 +89,9 @@ class BatchDataReader extends BaseBatchReader<FileScanTask>
   @Override
   public CustomTaskMetric[] currentMetricsValues() {
     return new CustomTaskMetric[] {
-      new TaskNumSplits(numSplits), new TaskNumDeletes(counter().get())
+      new TaskNumSplits(numSplits),
+      new TaskNumDeletes(counter().get()),
+      new TaskScanDuration(scanDurationNanos())
     };
   }
 
diff --git 
a/spark/v4.2/spark/src/main/java/org/apache/iceberg/spark/source/RowDataReader.java
 
b/spark/v4.2/spark/src/main/java/org/apache/iceberg/spark/source/RowDataReader.java
index dbfb0b7614..a60fbabcaf 100644
--- 
a/spark/v4.2/spark/src/main/java/org/apache/iceberg/spark/source/RowDataReader.java
+++ 
b/spark/v4.2/spark/src/main/java/org/apache/iceberg/spark/source/RowDataReader.java
@@ -33,6 +33,7 @@ import org.apache.iceberg.io.InputFile;
 import org.apache.iceberg.relocated.com.google.common.base.Preconditions;
 import org.apache.iceberg.spark.source.metrics.TaskNumDeletes;
 import org.apache.iceberg.spark.source.metrics.TaskNumSplits;
+import org.apache.iceberg.spark.source.metrics.TaskScanDuration;
 import org.apache.spark.rdd.InputFileBlockHolder;
 import org.apache.spark.sql.catalyst.InternalRow;
 import org.apache.spark.sql.connector.metric.CustomTaskMetric;
@@ -72,7 +73,9 @@ class RowDataReader extends BaseRowReader<FileScanTask> 
implements PartitionRead
   @Override
   public CustomTaskMetric[] currentMetricsValues() {
     return new CustomTaskMetric[] {
-      new TaskNumSplits(numSplits), new TaskNumDeletes(counter().get())
+      new TaskNumSplits(numSplits),
+      new TaskNumDeletes(counter().get()),
+      new TaskScanDuration(scanDurationNanos())
     };
   }
 
diff --git 
a/spark/v4.2/spark/src/main/java/org/apache/iceberg/spark/source/SparkScan.java 
b/spark/v4.2/spark/src/main/java/org/apache/iceberg/spark/source/SparkScan.java
index 22340408b0..c54e9c2dab 100644
--- 
a/spark/v4.2/spark/src/main/java/org/apache/iceberg/spark/source/SparkScan.java
+++ 
b/spark/v4.2/spark/src/main/java/org/apache/iceberg/spark/source/SparkScan.java
@@ -49,6 +49,7 @@ import org.apache.iceberg.spark.source.metrics.NumSplits;
 import org.apache.iceberg.spark.source.metrics.PositionalDeleteFiles;
 import org.apache.iceberg.spark.source.metrics.ResultDataFiles;
 import org.apache.iceberg.spark.source.metrics.ResultDeleteFiles;
+import org.apache.iceberg.spark.source.metrics.ScanDuration;
 import org.apache.iceberg.spark.source.metrics.ScannedDataManifests;
 import org.apache.iceberg.spark.source.metrics.ScannedDeleteManifests;
 import org.apache.iceberg.spark.source.metrics.SkippedDataFiles;
@@ -345,6 +346,7 @@ abstract class SparkScan implements Scan, 
SupportsReportStatistics {
 
       // common
       new TotalPlanningDuration(),
+      new ScanDuration(),
 
       // data manifests
       new TotalDataManifests(),
diff --git 
a/spark/v4.2/spark/src/main/java/org/apache/iceberg/spark/source/metrics/ScanDuration.java
 
b/spark/v4.2/spark/src/main/java/org/apache/iceberg/spark/source/metrics/ScanDuration.java
new file mode 100644
index 0000000000..49f55ab355
--- /dev/null
+++ 
b/spark/v4.2/spark/src/main/java/org/apache/iceberg/spark/source/metrics/ScanDuration.java
@@ -0,0 +1,78 @@
+/*
+ * 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.iceberg.spark.source.metrics;
+
+import java.util.Arrays;
+import java.util.Locale;
+import java.util.concurrent.TimeUnit;
+import org.apache.spark.sql.connector.metric.CustomSumMetric;
+
+public class ScanDuration extends CustomSumMetric {
+
+  public static final String NAME = "scanDuration";
+
+  @Override
+  public String name() {
+    return NAME;
+  }
+
+  @Override
+  public String description() {
+    return "total scan duration";
+  }
+
+  @Override
+  public String aggregateTaskMetrics(long[] taskMetrics) {
+    if (taskMetrics.length == 0) {
+      return format(0L);
+    }
+
+    long totalNanos = 0L;
+    for (long taskMetric : taskMetrics) {
+      totalNanos += taskMetric;
+    }
+
+    // sort a copy: Spark reuses the array it hands us
+    long[] sorted = Arrays.copyOf(taskMetrics, taskMetrics.length);
+    Arrays.sort(sorted);
+
+    // mirrors MetricUtils.stringValue for Spark's own timing metrics, so a 
straggler task is
+    // visible instead of being hidden by the total
+    return String.format(
+        Locale.ROOT,
+        "total (min, med, max)\n%s (%s, %s, %s)",
+        format(totalNanos),
+        format(sorted[0]),
+        format(sorted[sorted.length / 2]),
+        format(sorted[sorted.length - 1]));
+  }
+
+  private String format(long nanos) {
+    // raw nanos are unreadable on the UI, scale to the largest meaningful unit
+    if (nanos < TimeUnit.MICROSECONDS.toNanos(1)) {
+      return nanos + " ns";
+    } else if (nanos < TimeUnit.MILLISECONDS.toNanos(1)) {
+      return TimeUnit.NANOSECONDS.toMicros(nanos) + " us";
+    } else if (nanos < TimeUnit.SECONDS.toNanos(1)) {
+      return TimeUnit.NANOSECONDS.toMillis(nanos) + " ms";
+    } else {
+      return String.format(Locale.ROOT, "%.1f s", nanos / (double) 
TimeUnit.SECONDS.toNanos(1));
+    }
+  }
+}
diff --git 
a/spark/v4.2/spark/src/main/java/org/apache/iceberg/spark/source/metrics/TaskScanDuration.java
 
b/spark/v4.2/spark/src/main/java/org/apache/iceberg/spark/source/metrics/TaskScanDuration.java
new file mode 100644
index 0000000000..a505910514
--- /dev/null
+++ 
b/spark/v4.2/spark/src/main/java/org/apache/iceberg/spark/source/metrics/TaskScanDuration.java
@@ -0,0 +1,40 @@
+/*
+ * 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.iceberg.spark.source.metrics;
+
+import org.apache.spark.sql.connector.metric.CustomTaskMetric;
+
+public class TaskScanDuration implements CustomTaskMetric {
+
+  private final long value;
+
+  public TaskScanDuration(long value) {
+    this.value = value;
+  }
+
+  @Override
+  public String name() {
+    return ScanDuration.NAME;
+  }
+
+  @Override
+  public long value() {
+    return value;
+  }
+}
diff --git 
a/spark/v4.2/spark/src/test/java/org/apache/iceberg/spark/source/TestSparkReadMetrics.java
 
b/spark/v4.2/spark/src/test/java/org/apache/iceberg/spark/source/TestSparkReadMetrics.java
index 57b2c7146b..442c96ae6c 100644
--- 
a/spark/v4.2/spark/src/test/java/org/apache/iceberg/spark/source/TestSparkReadMetrics.java
+++ 
b/spark/v4.2/spark/src/test/java/org/apache/iceberg/spark/source/TestSparkReadMetrics.java
@@ -18,13 +18,16 @@
  */
 package org.apache.iceberg.spark.source;
 
+import static 
org.apache.iceberg.spark.source.SparkSQLExecutionHelper.lastExecutedMetricValue;
 import static org.assertj.core.api.Assertions.assertThat;
 import static scala.collection.JavaConverters.seqAsJavaListConverter;
 
 import java.util.List;
 import java.util.Map;
 import org.apache.iceberg.ParameterizedTestExtension;
+import org.apache.iceberg.TableProperties;
 import org.apache.iceberg.spark.TestBaseWithCatalog;
+import org.apache.iceberg.spark.source.metrics.ScanDuration;
 import org.apache.spark.sql.Dataset;
 import org.apache.spark.sql.Row;
 import org.apache.spark.sql.catalyst.analysis.NoSuchTableException;
@@ -63,6 +66,9 @@ public class TestSparkReadMetrics extends TestBaseWithCatalog 
{
     assertThat(metricsMap)
         .hasEntrySatisfying(
             "totalPlanningDuration", sqlMetric -> 
assertThat(sqlMetric.value()).isNotEqualTo(0));
+    assertThat(metricsMap)
+        .hasEntrySatisfying(
+            "scanDuration", sqlMetric -> 
assertThat(sqlMetric.value()).isGreaterThan(0));
 
     // data manifests
     assertThat(metricsMap)
@@ -139,6 +145,9 @@ public class TestSparkReadMetrics extends 
TestBaseWithCatalog {
     assertThat(metricsMap)
         .hasEntrySatisfying(
             "totalPlanningDuration", sqlMetric -> 
assertThat(sqlMetric.value()).isNotEqualTo(0));
+    assertThat(metricsMap)
+        .hasEntrySatisfying(
+            "scanDuration", sqlMetric -> 
assertThat(sqlMetric.value()).isGreaterThan(0));
 
     // data manifests
     assertThat(metricsMap)
@@ -194,6 +203,59 @@ public class TestSparkReadMetrics extends 
TestBaseWithCatalog {
             "skippedDeleteFiles", sqlMetric -> 
assertThat(sqlMetric.value()).isEqualTo(0));
   }
 
+  @TestTemplate
+  public void scanDurationAggregatedAcrossTasks() throws NoSuchTableException {
+    sql("CREATE TABLE %s (id BIGINT) USING iceberg", tableName);
+
+    spark.range(10000).coalesce(1).writeTo(tableName).append();
+    spark.range(10001, 20000).coalesce(1).writeTo(tableName).append();
+
+    Dataset<Row> df = spark.sql(String.format("SELECT * FROM %s", tableName));
+    assertThat(df.collectAsList()).hasSize(19999);
+
+    List<SparkPlan> sparkPlans =
+        
seqAsJavaListConverter(df.queryExecution().executedPlan().collectLeaves()).asJava();
+    Map<String, SQLMetric> metricsMap =
+        
JavaConverters.mapAsJavaMapConverter(sparkPlans.get(0).metrics()).asJava();
+
+    // the driver-side accumulator sums TaskScanDuration across all tasks of 
the scan
+    long aggregatedNanos = metricsMap.get("scanDuration").value();
+    assertThat(aggregatedNanos).isGreaterThan(0);
+
+    // the value the UI renders, read back from the SQL status store
+    assertThat(lastExecutedMetricValue(spark, new 
ScanDuration().description()))
+        .containsPattern(
+            "total \\(min, med, max\\)\\R"
+                + "\\d+(\\.\\d+)? (ns|us|ms|s) "
+                + "\\(\\d+(\\.\\d+)? (ns|us|ms|s), "
+                + "\\d+(\\.\\d+)? (ns|us|ms|s), "
+                + "\\d+(\\.\\d+)? (ns|us|ms|s)\\)");
+  }
+
+  @TestTemplate
+  public void scanDurationForNonVectorizedRead() throws NoSuchTableException {
+    // the other tests read through BatchDataReader, disable vectorization to 
cover RowDataReader
+    sql(
+        "CREATE TABLE %s (id BIGINT) USING iceberg TBLPROPERTIES 
('%s'='false')",
+        tableName, TableProperties.PARQUET_VECTORIZATION_ENABLED);
+
+    spark.range(10000).coalesce(1).writeTo(tableName).append();
+
+    Dataset<Row> df = spark.sql(String.format("SELECT * FROM %s", tableName));
+    df.collect();
+
+    List<SparkPlan> sparkPlans =
+        
seqAsJavaListConverter(df.queryExecution().executedPlan().collectLeaves()).asJava();
+    // sanity check that this really exercised the row-based path
+    assertThat(sparkPlans.get(0).supportsColumnar()).isFalse();
+
+    Map<String, SQLMetric> metricsMap =
+        
JavaConverters.mapAsJavaMapConverter(sparkPlans.get(0).metrics()).asJava();
+    assertThat(metricsMap)
+        .hasEntrySatisfying(
+            "scanDuration", sqlMetric -> 
assertThat(sqlMetric.value()).isGreaterThan(0));
+  }
+
   @TestTemplate
   public void testDeleteMetrics() throws NoSuchTableException {
     sql(
@@ -222,6 +284,9 @@ public class TestSparkReadMetrics extends 
TestBaseWithCatalog {
     assertThat(metricsMap)
         .hasEntrySatisfying(
             "totalPlanningDuration", sqlMetric -> 
assertThat(sqlMetric.value()).isNotEqualTo(0));
+    assertThat(metricsMap)
+        .hasEntrySatisfying(
+            "scanDuration", sqlMetric -> 
assertThat(sqlMetric.value()).isGreaterThan(0));
 
     // data manifests
     assertThat(metricsMap)
diff --git 
a/spark/v4.2/spark/src/test/java/org/apache/iceberg/spark/source/metrics/TestScanDuration.java
 
b/spark/v4.2/spark/src/test/java/org/apache/iceberg/spark/source/metrics/TestScanDuration.java
new file mode 100644
index 0000000000..9a1f653023
--- /dev/null
+++ 
b/spark/v4.2/spark/src/test/java/org/apache/iceberg/spark/source/metrics/TestScanDuration.java
@@ -0,0 +1,75 @@
+/*
+ * 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.iceberg.spark.source.metrics;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+import java.util.Locale;
+import org.junit.jupiter.api.Test;
+
+class TestScanDuration {
+
+  @Test
+  void aggregateTaskMetrics() {
+    ScanDuration metric = new ScanDuration();
+
+    assertThat(metric.aggregateTaskMetrics(new long[] {300L, 400L}))
+        .isEqualTo("total (min, med, max)\n700 ns (300 ns, 400 ns, 400 ns)");
+    assertThat(metric.aggregateTaskMetrics(new long[] {1_500L, 2_500L}))
+        .isEqualTo("total (min, med, max)\n4 us (1 us, 2 us, 2 us)");
+    assertThat(metric.aggregateTaskMetrics(new long[] {1_000_000L, 500_000L}))
+        .isEqualTo("total (min, med, max)\n1 ms (500 us, 1 ms, 1 ms)");
+    assertThat(metric.aggregateTaskMetrics(new long[] {1_500_000_000L, 
1_000_000_000L}))
+        .isEqualTo("total (min, med, max)\n2.5 s (1.0 s, 1.5 s, 1.5 s)");
+  }
+
+  @Test
+  void aggregateTaskMetricsWithNoTasks() {
+    assertThat(new ScanDuration().aggregateTaskMetrics(new long[] 
{})).isEqualTo("0 ns");
+  }
+
+  @Test
+  void aggregateTaskMetricsSurfacesStragglers() {
+    // the total alone would hide that one task took the bulk of the time
+    assertThat(
+            new ScanDuration()
+                .aggregateTaskMetrics(new long[] {1_000_000L, 1_000_000L, 
8_000_000L}))
+        .isEqualTo("total (min, med, max)\n10 ms (1 ms, 1 ms, 8 ms)");
+  }
+
+  @Test
+  void aggregateTaskMetricsDoesNotMutateInput() {
+    long[] taskMetrics = new long[] {3_000_000L, 1_000_000L, 2_000_000L};
+    new ScanDuration().aggregateTaskMetrics(taskMetrics);
+    assertThat(taskMetrics).containsExactly(3_000_000L, 1_000_000L, 
2_000_000L);
+  }
+
+  @Test
+  void aggregateTaskMetricsIsLocaleIndependent() {
+    Locale previous = Locale.getDefault();
+    try {
+      // a comma-decimal locale would render "2,5 s" without an explicit 
Locale.ROOT
+      Locale.setDefault(Locale.GERMANY);
+      assertThat(new ScanDuration().aggregateTaskMetrics(new long[] 
{2_500_000_000L}))
+          .isEqualTo("total (min, med, max)\n2.5 s (2.5 s, 2.5 s, 2.5 s)");
+    } finally {
+      Locale.setDefault(previous);
+    }
+  }
+}

Reply via email to