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

szehon-ho pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/spark.git


The following commit(s) were added to refs/heads/master by this push:
     new 911f372d388d [SPARK-58183][SQL] Report observation metrics from the 
last successful task attempt
911f372d388d is described below

commit 911f372d388d259aa120a8672fc5a6cd8effd74d
Author: Szehon Ho <[email protected]>
AuthorDate: Thu Jul 23 17:11:08 2026 -0700

    [SPARK-58183][SQL] Report observation metrics from the last successful task 
attempt
    
    ### What changes were proposed in this pull request?
    
    Observation metrics (`Dataset.observe` / `CollectMetrics`, surfaced via 
`CollectMetricsExec`) are aggregated on the driver through an 
`AggregatingAccumulator`. When a task, stage, or RDD is recomputed 
(speculation, fetch-failure retries, AQE stage cancellation/replanning), the 
accumulator receives updates from *every* attempt and merges them together, so 
the reported metric can reflect superseded attempts (e.g. double-counted rows 
or an over-sized `collect_list`).
    
    This PR makes observation metrics report only the values produced by the 
last successful attempt:
    
    - Adds `LastAttemptAggregatingAccumulator`, a subclass of 
`AggregatingAccumulator` that mixes in the core `LastAttemptAccumulator` trait. 
It tracks per-RDD / per-stage-attempt / per-partition contributions and can 
return the aggregate for the highest RDD id (`lastAttemptValueForHighestRDDId`).
      - `partialMergeVal` snapshots the (mutable) aggregate buffer via 
`buffer.copy()`; `partialMerge` merges a snapshot back in.
      - `isMergeable` guards merges by comparing an eagerly-captured 
`outputSchema` (captured before task serialization drops the transient result 
expressions on task-side copies).
      - `add` mirrors direct driver-side updates into the last-attempt state.
    - `AggregatingAccumulator` gains a `lastAttempt(...)` factory (alongside 
refactoring to allow subclassing).
    - `CollectMetricsExec` now creates the last-attempt accumulator, 
initializes/resets its last-attempt state, and reads the collected metrics from 
`lastAttemptValueForHighestRDDId()` (falling back to `value`).
    
    ### Why are the changes needed?
    
    Observation metrics are supposed to describe the data that actually flowed 
through the query. Under retries/replans the existing accumulator over-counts 
by merging superseded attempts, which is incorrect and non-deterministic. This 
is especially visible for non-idempotent aggregate buffers (e.g. 
`collect_list`), and for AQE, where a replanned stage produces a brand-new RDD 
whose earlier attempt should not be counted.
    
    ### Does this PR introduce _any_ user-facing change?
    
    Yes. When a stage/RDD backing an observed operator is recomputed, the 
observed metric values now reflect only the last successful attempt instead of 
a merge across all attempts. Without recomputation, the reported values are 
unchanged.
    
    ### How was this patch tested?
    
    - `AggregatingAccumulatorSuite`: existing cases parameterized over 
`lastAttempt = {true, false}`, plus new last-attempt-specific cases 
(schema-mismatch invalidation, mixed driver/task updates, highest-RDD-id 
selection, skipped uncomputed partitions, latest stage attempt per partition).
    - `MetricsFailureInjectionSuite`: new end-to-end tests under 
shuffle-fetch-failure and AQE-cancellation injection that observe a mix of 
declarative / imperative / typed-imperative aggregates. 
`size(collect_list(...)) == 300` deterministically proves last-attempt 
selection for a non-idempotent typed buffer. Also adds coverage for observation 
on a non-leaf (post-shuffle) stage, multiple observation points in one query, 
and last-attempt state reset across reuse of the same `Dataset`.
    - `DataFrameCallbackSuite`: additional observation coverage.
    
    All of `MetricsFailureInjectionSuite` (20 tests) passes locally.
    
    ### Was this patch authored or co-authored using generative AI tooling?
    
    Generated-by: Cursor (Opus 4.8)
    
    Closes #57323 from szehon-ho/SPARK-collect-metrics-last-attempt.
    
    Authored-by: Szehon Ho <[email protected]>
    Signed-off-by: Szehon Ho <[email protected]>
---
 docs/sql-migration-guide.md                        |   1 +
 .../org/apache/spark/sql/internal/SQLConf.scala    |  10 +
 .../sql/execution/AggregatingAccumulator.scala     | 138 ++++--
 .../spark/sql/execution/CollectMetricsExec.scala   |  23 +-
 .../LastAttemptAggregatingAccumulator.scala        |  91 ++++
 .../execution/AggregatingAccumulatorSuite.scala    | 512 ++++++++++++++++-----
 .../metric/MetricsFailureInjectionSuite.scala      | 274 ++++++++++-
 .../spark/sql/util/DataFrameCallbackSuite.scala    |  37 ++
 8 files changed, 924 insertions(+), 162 deletions(-)

diff --git a/docs/sql-migration-guide.md b/docs/sql-migration-guide.md
index 9b4db592b0b3..c0cc988f113f 100644
--- a/docs/sql-migration-guide.md
+++ b/docs/sql-migration-guide.md
@@ -26,6 +26,7 @@ license: |
 
 - Since Spark 4.3, [ASOF JOIN](sql-ref-syntax-qry-select-asof-join.html) is 
available as an opt-in SQL feature gated by `spark.sql.join.asofJoin.enabled` 
(default `false`). When disabled, `ASOF JOIN` fails at parse time with 
`UNSUPPORTED_FEATURE.ASOF_JOIN`.
 - Since Spark 4.3, zero-length files are skipped during Parquet schema 
inference instead of failing with a `FAILED_READ_FILE.CANNOT_READ_FILE_FOOTER` 
error.
+- Since Spark 4.3, metrics produced by `Dataset.observe` include values from 
only the last successful task attempts instead of aggregating values from all 
attempts. To restore the previous behavior, set 
`spark.sql.legacy.observeMetricsAggregateAllAttempts` to `true`.
 - Since Spark 4.3, the configuration key 
`spark.sql.sources.v2.bucketing.allowJoinKeysSubsetOfPartitionKeys.enabled` has 
been renamed to 
`spark.sql.sources.v2.bucketing.allowKeysSubsetOfPartitionKeys.enabled` to 
reflect that it now applies to storage-partitioned joins, aggregates, and 
windows. The old key continues to work as an alias.
 - Since Spark 4.3, the Spark Thrift Server rejects setting JVM system 
properties through the `set:system:` session configuration overlay (for 
example, in a JDBC connection string). To restore the previous behavior, set 
`spark.sql.legacy.hive.thriftServer.allowSettingSystemProperties` to `true`.
 - Since Spark 4.3, the adaptive execution rule 
`org.apache.spark.sql.execution.adaptive.DynamicJoinSelection` has been renamed 
to `DemoteBroadcastHashJoin`, which now only demotes broadcast hash joins 
(emitting `NO_BROADCAST_HASH`). Its selection of shuffled hash join over sort 
merge join has moved to a new physical rule gated by 
`spark.sql.adaptive.convertSortMergeJoinToShuffledHashJoin.enabled` (default 
`true`). If you previously disabled the shuffled-hash-join preference by 
listing `o [...]
diff --git 
a/sql/catalyst/src/main/scala/org/apache/spark/sql/internal/SQLConf.scala 
b/sql/catalyst/src/main/scala/org/apache/spark/sql/internal/SQLConf.scala
index 5af33da7ae8e..edb99889f3b4 100644
--- a/sql/catalyst/src/main/scala/org/apache/spark/sql/internal/SQLConf.scala
+++ b/sql/catalyst/src/main/scala/org/apache/spark/sql/internal/SQLConf.scala
@@ -6222,6 +6222,16 @@ object SQLConf {
       .booleanConf
       .createWithDefault(false)
 
+  val LEGACY_OBSERVE_METRICS_AGGREGATE_ALL_ATTEMPTS =
+    buildConf("spark.sql.legacy.observeMetricsAggregateAllAttempts")
+      .internal()
+      .doc("When true, metrics produced by `Dataset.observe` aggregate values 
from all task " +
+        "attempts. When false, only values from the last successful task 
attempts are used.")
+      .version("4.3.0")
+      .withBindingPolicy(ConfigBindingPolicy.NOT_APPLICABLE)
+      .booleanConf
+      .createWithDefault(false)
+
   val MAX_TO_STRING_FIELDS = buildConf("spark.sql.debug.maxToStringFields")
     .doc("Maximum number of fields of sequence-like entries can be converted 
to strings " +
       "in debug output. Any elements beyond the limit will be dropped and 
replaced by a " +
diff --git 
a/sql/core/src/main/scala/org/apache/spark/sql/execution/AggregatingAccumulator.scala
 
b/sql/core/src/main/scala/org/apache/spark/sql/execution/AggregatingAccumulator.scala
index bb785bfd18f0..530d58b57c3d 100644
--- 
a/sql/core/src/main/scala/org/apache/spark/sql/execution/AggregatingAccumulator.scala
+++ 
b/sql/core/src/main/scala/org/apache/spark/sql/execution/AggregatingAccumulator.scala
@@ -18,7 +18,7 @@ package org.apache.spark.sql.execution
 
 import scala.collection.mutable
 
-import org.apache.spark.TaskContext
+import org.apache.spark.{SparkContext, TaskContext}
 import org.apache.spark.sql.catalyst.InternalRow
 import org.apache.spark.sql.catalyst.expressions.{Attribute, 
AttributeReference, AttributeSeq, BindReferences, Expression, 
InterpretedMutableProjection, InterpretedUnsafeProjection, JoinedRow, 
MutableProjection, NamedExpression, Projection, SpecificInternalRow}
 import 
org.apache.spark.sql.catalyst.expressions.aggregate.{AggregateExpression, 
DeclarativeAggregate, ImperativeAggregate, NoOp, TypedImperativeAggregate}
@@ -30,7 +30,7 @@ import org.apache.spark.util.AccumulatorV2
 /**
  * Accumulator that computes a global aggregate.
  */
-class AggregatingAccumulator private(
+class AggregatingAccumulator protected(
     bufferSchema: Seq[DataType],
     initialValues: Seq[Expression],
     updateExpressions: Seq[Expression],
@@ -49,7 +49,7 @@ class AggregatingAccumulator private(
   @transient
   private var joinedRow: JoinedRow = _
 
-  private var buffer: SpecificInternalRow = _
+  protected var buffer: SpecificInternalRow = _
 
   private def createBuffer(): SpecificInternalRow = {
     val buffer = new SpecificInternalRow(bufferSchema)
@@ -156,28 +156,7 @@ class AggregatingAccumulator private(
     if (!other.isZero) {
       other match {
         case agg: AggregatingAccumulator =>
-          val buffer = getOrCreateBuffer()
-          val otherBuffer = agg.buffer
-          mergeProjection.target(buffer)(joinedRow.withRight(otherBuffer))
-          var i = 0
-          while (i < imperatives.length) {
-            imperatives(i).merge(buffer, otherBuffer)
-            i += 1
-          }
-          i = 0
-          if (isAtDriverSide) {
-            while (i < typedImperatives.length) {
-              // The input buffer stores serialized data
-              typedImperatives(i).merge(buffer, otherBuffer)
-              i += 1
-            }
-          } else {
-            while (i < typedImperatives.length) {
-              // The input buffer stores deserialized object
-              typedImperatives(i).mergeBuffersObjects(buffer, otherBuffer)
-              i += 1
-            }
-          }
+          mergeBuffer(agg.buffer)
         case _ =>
           throw QueryExecutionErrors.cannotMergeClassWithOtherClassError(
             this.getClass.getName, other.getClass.getName)
@@ -185,6 +164,30 @@ class AggregatingAccumulator private(
     }
   }
 
+  protected def mergeBuffer(otherBuffer: InternalRow): Unit = 
withSQLConf(true, ()) {
+    val buffer = getOrCreateBuffer()
+    mergeProjection.target(buffer)(joinedRow.withRight(otherBuffer))
+    var i = 0
+    while (i < imperatives.length) {
+      imperatives(i).merge(buffer, otherBuffer)
+      i += 1
+    }
+    i = 0
+    if (isAtDriverSide) {
+      while (i < typedImperatives.length) {
+        // The input buffer stores serialized data
+        typedImperatives(i).merge(buffer, otherBuffer)
+        i += 1
+      }
+    } else {
+      while (i < typedImperatives.length) {
+        // The input buffer stores a deserialized object
+        typedImperatives(i).mergeBuffersObjects(buffer, otherBuffer)
+        i += 1
+      }
+    }
+  }
+
   override def value: InternalRow = withSQLConf(false, InternalRow.empty) {
     // Either use the existing buffer or create a temporary one.
     val input = if (!isZero) {
@@ -234,11 +237,24 @@ class AggregatingAccumulator private(
   }
 }
 
+private case class AggregatingAccumulatorArgs(
+    bufferSchema: Seq[DataType],
+    initialValues: Seq[Expression],
+    updateExpressions: Seq[Expression],
+    mergeExpressions: Seq[Expression],
+    resultExpressions: Seq[Expression],
+    imperatives: Array[ImperativeAggregate],
+    typedImperatives: Array[TypedImperativeAggregate[_]],
+    conf: SQLConf)
+
 object AggregatingAccumulator {
   /**
-   * Create an aggregating accumulator for the given functions and input 
schema.
+   * Build the bound expressions and aggregate buffers shared by every flavor 
of aggregating
+   * accumulator created for the given functions and input schema.
    */
-  def apply(functions: Seq[Expression], inputAttributes: Seq[Attribute]): 
AggregatingAccumulator = {
+  private def buildAccumulatorArgs(
+      functions: Seq[Expression],
+      inputAttributes: Seq[Attribute]): AggregatingAccumulatorArgs = {
     // There are a couple of things happening here:
     // - Collect the schema's of the aggregate and input aggregate buffers. 
These are needed to bind
     //   the expressions which will be done when we create the accumulator.
@@ -288,8 +304,7 @@ object AggregatingAccumulator {
     val mergeAttrSeq: AttributeSeq = (aggBufferAttributes ++ 
inputAggBufferAttributes).toSeq
     val aggBufferAttributesSeq: AttributeSeq = aggBufferAttributes.toSeq
 
-    // Create the accumulator.
-    new AggregatingAccumulator(
+    AggregatingAccumulatorArgs(
       aggBufferAttributes.map(_.dataType).toSeq,
       initialValues.toSeq,
       updateExpressions.map(BindReferences.bindReference(_, 
updateAttrSeq)).toSeq,
@@ -299,4 +314,69 @@ object AggregatingAccumulator {
       typedImperatives.toArray,
       SQLConf.get)
   }
+
+  /**
+   * Create an aggregating accumulator for the given functions and input 
schema.
+   */
+  def apply(functions: Seq[Expression], inputAttributes: Seq[Attribute]): 
AggregatingAccumulator = {
+    val c = buildAccumulatorArgs(functions, inputAttributes)
+    new AggregatingAccumulator(
+      c.bufferSchema,
+      c.initialValues,
+      c.updateExpressions,
+      c.mergeExpressions,
+      c.resultExpressions,
+      c.imperatives,
+      c.typedImperatives,
+      c.conf)
+  }
+
+  /**
+   * Create an aggregating accumulator for the given functions and input 
schema, and register it
+   * with the given SparkContext under `name`.
+   */
+  def apply(
+      sc: SparkContext,
+      name: String,
+      functions: Seq[Expression],
+      inputAttributes: Seq[Attribute]): AggregatingAccumulator = {
+    val acc = apply(functions, inputAttributes)
+    acc.register(sc, Option(name))
+    acc
+  }
+
+  /**
+   * Create a last-attempt aggregating accumulator for the given functions and 
input schema.
+   */
+  def lastAttempt(
+      functions: Seq[Expression],
+      inputAttributes: Seq[Attribute]): LastAttemptAggregatingAccumulator = {
+    val c = buildAccumulatorArgs(functions, inputAttributes)
+    new LastAttemptAggregatingAccumulator(
+      c.bufferSchema,
+      c.initialValues,
+      c.updateExpressions,
+      c.mergeExpressions,
+      c.resultExpressions,
+      c.imperatives,
+      c.typedImperatives,
+      c.conf)
+  }
+
+  /**
+   * Create a last-attempt aggregating accumulator for the given functions and 
input schema,
+   * register it with the given SparkContext under `name`, and initialize its 
last-attempt
+   * tracking state.
+   */
+  def lastAttempt(
+      sc: SparkContext,
+      name: String,
+      functions: Seq[Expression],
+      inputAttributes: Seq[Attribute]): LastAttemptAggregatingAccumulator = {
+    val acc = lastAttempt(functions, inputAttributes)
+    acc.register(sc, Option(name))
+    acc.initializeLastAttemptAccumulator()
+    acc
+  }
+
 }
diff --git 
a/sql/core/src/main/scala/org/apache/spark/sql/execution/CollectMetricsExec.scala
 
b/sql/core/src/main/scala/org/apache/spark/sql/execution/CollectMetricsExec.scala
index fd89be2368af..3dd81ff2af6a 100644
--- 
a/sql/core/src/main/scala/org/apache/spark/sql/execution/CollectMetricsExec.scala
+++ 
b/sql/core/src/main/scala/org/apache/spark/sql/execution/CollectMetricsExec.scala
@@ -25,6 +25,7 @@ import 
org.apache.spark.sql.catalyst.plans.physical.Partitioning
 import org.apache.spark.sql.catalyst.types.DataTypeUtils
 import org.apache.spark.sql.execution.adaptive.AdaptiveSparkPlanHelper
 import org.apache.spark.sql.execution.columnar.InMemoryTableScanExec
+import org.apache.spark.sql.internal.SQLConf
 import org.apache.spark.sql.types.StructType
 
 /**
@@ -37,9 +38,13 @@ case class CollectMetricsExec(
   extends UnaryExecNode {
 
   private lazy val accumulator: AggregatingAccumulator = {
-    val acc = AggregatingAccumulator(metricExpressions, child.output)
-    acc.register(sparkContext, Option(CollectMetricsExec.ACCUMULATOR_NAME))
-    acc
+    if (conf.getConf(SQLConf.LEGACY_OBSERVE_METRICS_AGGREGATE_ALL_ATTEMPTS)) {
+      AggregatingAccumulator(
+        sparkContext, CollectMetricsExec.ACCUMULATOR_NAME, metricExpressions, 
child.output)
+    } else {
+      AggregatingAccumulator.lastAttempt(
+        sparkContext, CollectMetricsExec.ACCUMULATOR_NAME, metricExpressions, 
child.output)
+    }
   }
 
   private[sql] def accumulatorId: Long = {
@@ -56,7 +61,13 @@ case class CollectMetricsExec(
       .asInstanceOf[InternalRow => Row]
   }
 
-  def collectedMetrics: Row = toRowConverter(accumulator.value)
+  def collectedMetrics: Row = toRowConverter(collectedMetricsRow)
+
+  private def collectedMetricsRow: InternalRow = accumulator match {
+    case acc: LastAttemptAggregatingAccumulator =>
+      acc.lastAttemptValueForHighestRDDId().getOrElse(acc.value)
+    case acc => acc.value
+  }
 
   override def output: Seq[Attribute] = child.output
 
@@ -66,6 +77,10 @@ case class CollectMetricsExec(
 
   override def resetMetrics(): Unit = {
     accumulator.reset()
+    accumulator match {
+      case acc: LastAttemptAggregatingAccumulator => 
acc.resetLastAttemptAccumulator()
+      case _ =>
+    }
     super.resetMetrics()
   }
 
diff --git 
a/sql/core/src/main/scala/org/apache/spark/sql/execution/LastAttemptAggregatingAccumulator.scala
 
b/sql/core/src/main/scala/org/apache/spark/sql/execution/LastAttemptAggregatingAccumulator.scala
new file mode 100644
index 000000000000..f806fcf6b98c
--- /dev/null
+++ 
b/sql/core/src/main/scala/org/apache/spark/sql/execution/LastAttemptAggregatingAccumulator.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.execution
+
+import org.apache.spark.sql.catalyst.InternalRow
+import org.apache.spark.sql.catalyst.expressions.Expression
+import org.apache.spark.sql.catalyst.expressions.aggregate.ImperativeAggregate
+import 
org.apache.spark.sql.catalyst.expressions.aggregate.TypedImperativeAggregate
+import org.apache.spark.sql.internal.SQLConf
+import org.apache.spark.sql.types.DataType
+import org.apache.spark.util.{AccumulatorV2, LastAttemptAccumulator, Utils}
+
+class LastAttemptAggregatingAccumulator private[execution](
+    bufferSchema: Seq[DataType],
+    initialValues: Seq[Expression],
+    updateExpressions: Seq[Expression],
+    mergeExpressions: Seq[Expression],
+    @transient resultExpressions: Seq[Expression],
+    imperatives: Array[ImperativeAggregate],
+    typedImperatives: Array[TypedImperativeAggregate[_]],
+    @transient conf: SQLConf)
+  extends AggregatingAccumulator(
+    bufferSchema,
+    initialValues,
+    updateExpressions,
+    mergeExpressions,
+    resultExpressions,
+    imperatives,
+    typedImperatives,
+    conf)
+  with LastAttemptAccumulator[InternalRow, InternalRow, InternalRow] {
+
+  // Keep a serializable copy because both the base field and the 
resultExpressions constructor
+  // parameter are @transient. Executor-side copyAndReset() needs it below.
+  private val serializedResultExpressions: Seq[Expression] = resultExpressions
+
+  // Keep a serializable snapshot because the base schema depends on its 
transient
+  // resultExpressions field. isMergeable() needs it below after task 
serialization.
+  private val serializedOutputSchema = schema
+
+  override protected def partialMergeVal: InternalRow = {
+    // The aggregate buffer is mutable, so last-attempt tracking keeps a 
snapshot.
+    if (buffer == null) null else buffer.copy()
+  }
+
+  override protected def partialMerge(otherVal: InternalRow): Unit = {
+    if (otherVal != null) {
+      mergeBuffer(otherVal)
+    }
+  }
+
+  override protected def isMergeable(other: AccumulatorV2[_, _]): Boolean = 
other match {
+    case o: LastAttemptAggregatingAccumulator =>
+      o.serializedOutputSchema == serializedOutputSchema
+    case _ => false
+  }
+
+  override protected def accumulatorStoresUserData: Boolean = true
+
+  override def copyAndReset(): LastAttemptAggregatingAccumulator =
+    new LastAttemptAggregatingAccumulator(
+      bufferSchema,
+      initialValues,
+      updateExpressions,
+      mergeExpressions,
+      serializedResultExpressions,
+      imperatives,
+      typedImperatives,
+      conf)
+
+  override def add(v: InternalRow): Unit = {
+    super.add(v)
+    if (isAtDriverSide && !Utils.isInRunningSparkTask) {
+      setValueIfOnDriverSide(value.copy())
+    }
+  }
+}
diff --git 
a/sql/core/src/test/scala/org/apache/spark/sql/execution/AggregatingAccumulatorSuite.scala
 
b/sql/core/src/test/scala/org/apache/spark/sql/execution/AggregatingAccumulatorSuite.scala
index 42a20c7ae800..f59945f65cca 100644
--- 
a/sql/core/src/test/scala/org/apache/spark/sql/execution/AggregatingAccumulatorSuite.scala
+++ 
b/sql/core/src/test/scala/org/apache/spark/sql/execution/AggregatingAccumulatorSuite.scala
@@ -19,9 +19,11 @@ package org.apache.spark.sql.execution
 import java.util.Properties
 
 import org.apache.spark.{TaskContext, TaskContextImpl}
+import org.apache.spark.scheduler.{TaskInfo, TaskLocality}
 import org.apache.spark.sql.catalyst.InternalRow
 import org.apache.spark.sql.catalyst.dsl.expressions._
-import org.apache.spark.sql.catalyst.expressions.{ExpressionEvalHelper, If, 
SortArray, SparkPartitionID, SpecificInternalRow}
+import org.apache.spark.sql.catalyst.expressions.{Attribute, Expression, 
ExpressionEvalHelper, If,
+  SortArray, SparkPartitionID, SpecificInternalRow}
 import org.apache.spark.sql.catalyst.expressions.aggregate.CollectSet
 import org.apache.spark.sql.catalyst.util.GenericArrayData
 import org.apache.spark.sql.test.SharedSparkSession
@@ -40,131 +42,184 @@ class AggregatingAccumulatorSuite
   private val inputAttributes = Seq(a, b, c)
   private def str(s: String): UTF8String = UTF8String.fromString(s)
 
+  private def newAccumulator(
+      lastAttempt: Boolean,
+      functions: Seq[Expression],
+      inputAttributes: Seq[Attribute]): AggregatingAccumulator = {
+    if (lastAttempt) {
+      AggregatingAccumulator.lastAttempt(functions, inputAttributes)
+    } else {
+      AggregatingAccumulator(functions, inputAttributes)
+    }
+  }
+
+  /** Creates a last-attempt accumulator, registers it, and initializes its 
tracking state. */
+  private def registeredLastAttempt(
+      functions: Seq[Expression],
+      inputAttributes: Seq[Attribute]): LastAttemptAggregatingAccumulator = {
+    AggregatingAccumulator.lastAttempt(
+      sparkContext, "test accumulator", functions, inputAttributes)
+  }
+
+  /** Builds a non-zero task-side accumulator by copying `main` and adding 
`rows`. */
+  private def taskUpdate(
+      main: LastAttemptAggregatingAccumulator,
+      rows: InternalRow*): LastAttemptAggregatingAccumulator = {
+    val taskAcc = main.copyAndReset()
+    rows.foreach(taskAcc.add)
+    taskAcc
+  }
+
+  private def taskInfo(partitionId: Int, attemptNumber: Int = 0): TaskInfo = {
+    new TaskInfo(
+      taskId = partitionId.toLong,
+      index = partitionId,
+      attemptNumber = attemptNumber,
+      partitionId = partitionId,
+      launchTime = 0L,
+      executorId = "executor",
+      host = "localhost",
+      taskLocality = TaskLocality.ANY,
+      speculative = false)
+  }
+
   test("empty aggregation") {
-    val acc1 = AggregatingAccumulator(
-      Seq(sum(a) + 1L as "sum_a", max(b) as "max_b", approxCountDistinct(c) as 
"acntd_c"),
-      inputAttributes)
-    val expectedSchema = new StructType()
-      .add("sum_a", "long")
-      .add("max_b", "string")
-      .add("acntd_c", "long", nullable = false)
-    assert(acc1.schema === expectedSchema)
-
-    val accEmpty = acc1.copy()
-    val acc2 = acc1.copy()
-
-    // Merge empty
-    acc1.merge(accEmpty)
-    assert(acc1.isZero)
-
-    // No updates
-    assert(acc1.isZero)
-    checkResult(acc1.value, InternalRow(null, null, 0), expectedSchema, false)
-    assert(acc1.isZero)
-
-    // A few updates
-    acc1.add(InternalRow(4L, str("foo"), 4.9d))
-    acc1.add(InternalRow(98L, str("bar"), -323.9d))
-    acc1.add(InternalRow(-30L, str("baz"), 4129.8d))
-    assert(!acc1.isZero)
-    checkResult(acc1.value, InternalRow(73L, str("baz"), 3L), expectedSchema, 
false)
-
-    // Idempotency of result
-    checkResult(acc1.value, InternalRow(73L, str("baz"), 3L), expectedSchema, 
false)
-
-    // A few updates to the copied accumulator using an updater
-    val updater = acc2.copyAndReset()
-    updater.add(InternalRow(-2L, str("qwerty"), -6773.9d))
-    updater.add(InternalRow(-35L, str("zzz-top"), -323.9d))
-    assert(acc2.isZero)
-    acc2.setState(updater)
-    checkResult(acc2.value, InternalRow(-36L, str("zzz-top"), 2L), 
expectedSchema, false)
-
-    // Merge accumulators
-    acc1.merge(acc2)
-    acc1.merge(acc2)
-    acc1.merge(accEmpty)
-    acc1.merge(accEmpty)
-    checkResult(acc1.value, InternalRow(1L, str("zzz-top"), 5L), 
expectedSchema, false)
-
-    // Reset
-    acc1.reset()
-    assert(acc1.isZero)
+    Seq(false, true).foreach { lastAttempt =>
+      val acc1 = newAccumulator(
+        lastAttempt,
+        Seq(sum(a) + 1L as "sum_a", max(b) as "max_b", approxCountDistinct(c) 
as "acntd_c"),
+        inputAttributes)
+      val expectedSchema = new StructType()
+        .add("sum_a", "long")
+        .add("max_b", "string")
+        .add("acntd_c", "long", nullable = false)
+      assert(acc1.schema === expectedSchema)
+
+      val accEmpty = acc1.copy()
+      val acc2 = acc1.copy()
+
+      // Merge empty
+      acc1.merge(accEmpty)
+      assert(acc1.isZero)
+
+      // No updates
+      assert(acc1.isZero)
+      checkResult(acc1.value, InternalRow(null, null, 0), expectedSchema, 
false)
+      assert(acc1.isZero)
+
+      // A few updates
+      acc1.add(InternalRow(4L, str("foo"), 4.9d))
+      acc1.add(InternalRow(98L, str("bar"), -323.9d))
+      acc1.add(InternalRow(-30L, str("baz"), 4129.8d))
+      assert(!acc1.isZero)
+      checkResult(acc1.value, InternalRow(73L, str("baz"), 3L), 
expectedSchema, false)
+
+      // Idempotency of result
+      checkResult(acc1.value, InternalRow(73L, str("baz"), 3L), 
expectedSchema, false)
+
+      // A few updates to the copied accumulator using an updater
+      val updater = acc2.copyAndReset()
+      updater.add(InternalRow(-2L, str("qwerty"), -6773.9d))
+      updater.add(InternalRow(-35L, str("zzz-top"), -323.9d))
+      assert(acc2.isZero)
+      acc2.setState(updater)
+      checkResult(acc2.value, InternalRow(-36L, str("zzz-top"), 2L), 
expectedSchema, false)
+
+      // Merge accumulators
+      acc1.merge(acc2)
+      acc1.merge(acc2)
+      acc1.merge(accEmpty)
+      acc1.merge(accEmpty)
+      checkResult(acc1.value, InternalRow(1L, str("zzz-top"), 5L), 
expectedSchema, false)
+
+      // Reset
+      acc1.reset()
+      assert(acc1.isZero)
+    }
   }
 
   test("non-deterministic expressions") {
-    val acc_driver = AggregatingAccumulator(
-      Seq(
-        min(SparkPartitionID()) as "min_pid",
-        max(SparkPartitionID()) as "max_pid",
-        SparkPartitionID()),
-      Nil)
-    checkResult(acc_driver.value, InternalRow(null, null, 0), 
acc_driver.schema, false)
-
-    def inPartition(id: Int)(f: => Unit): Unit = {
-      val ctx = new TaskContextImpl(0, 0, 1, 0, 0, 1, null, new Properties, 
null)
-      TaskContext.setTaskContext(ctx)
-      try {
-        f
-      } finally {
-        TaskContext.unset()
+    Seq(false, true).foreach { lastAttempt =>
+      val accDriver = newAccumulator(
+        lastAttempt,
+        Seq(
+          min(SparkPartitionID()) as "min_pid",
+          max(SparkPartitionID()) as "max_pid",
+          SparkPartitionID()),
+        Nil)
+      checkResult(accDriver.value, InternalRow(null, null, 0), 
accDriver.schema, false)
+
+      def inPartition(id: Int)(f: => Unit): Unit = {
+        val ctx = new TaskContextImpl(0, 0, 1, 0, 0, 1, null, new Properties, 
null)
+        TaskContext.setTaskContext(ctx)
+        try {
+          f
+        } finally {
+          TaskContext.unset()
+        }
       }
-    }
 
-    val acc1 = acc_driver.copy()
-    inPartition(3) {
-      acc1.add(InternalRow.empty)
-    }
-    val acc2 = acc_driver.copy()
-    inPartition(42) {
-      acc2.add(InternalRow.empty)
-    }
-    val acc3 = acc_driver.copy()
-    inPartition(96) {
-      acc3.add(InternalRow.empty)
-    }
+      val acc1 = accDriver.copy()
+      inPartition(3) {
+        acc1.add(InternalRow.empty)
+      }
+      val acc2 = accDriver.copy()
+      inPartition(42) {
+        acc2.add(InternalRow.empty)
+      }
+      val acc3 = accDriver.copy()
+      inPartition(96) {
+        acc3.add(InternalRow.empty)
+      }
 
-    acc_driver.merge(acc1)
-    acc_driver.merge(acc2)
-    acc_driver.merge(acc3)
-    assert(!acc_driver.isZero)
-    checkResult(acc_driver.value, InternalRow(3, 96, 0), acc_driver.schema, 
false)
+      accDriver.merge(acc1)
+      accDriver.merge(acc2)
+      accDriver.merge(acc3)
+      assert(!accDriver.isZero)
+      checkResult(accDriver.value, InternalRow(3, 96, 0), accDriver.schema, 
false)
+    }
   }
 
   test("collect agg metrics on job") {
-    val acc = AggregatingAccumulator(
-      Seq(
-        avg(a) + 1.0d as "avg_a",
-        sum(a + 10L) as "sum_a",
-        min(b) as "min_b",
-        max(b) as "max_b",
-        approxCountDistinct(b) as "acntd_b",
-        SortArray(CollectSet(If(a < 1000L, a % 3L, a % 
6L)).toAggregateExpression(), true)
-          as "item_set",
-        min(SparkPartitionID()) as "min_pid",
-        max(SparkPartitionID()) as "max_pid",
-        SparkPartitionID()),
-      Seq(a, b))
-    sparkContext.register(acc)
-    def consume(ids: Iterator[Long]): Unit = {
-      val row = new SpecificInternalRow(Seq(LongType, StringType))
-      ids.foreach { id =>
-        // Create the new row values.
-        row.setLong(0, id)
-        row.update(1, UTF8String.fromString(f"val_$id%06d"))
-
-        // Update the accumulator
-        acc.add(row)
+    Seq(false, true).foreach { lastAttempt =>
+      val acc = newAccumulator(
+        lastAttempt,
+        Seq(
+          avg(a) + 1.0d as "avg_a",
+          sum(a + 10L) as "sum_a",
+          min(b) as "min_b",
+          max(b) as "max_b",
+          approxCountDistinct(b) as "acntd_b",
+          SortArray(CollectSet(If(a < 1000L, a % 3L, a % 
6L)).toAggregateExpression(), true)
+            as "item_set",
+          min(SparkPartitionID()) as "min_pid",
+          max(SparkPartitionID()) as "max_pid",
+          SparkPartitionID()),
+        Seq(a, b))
+      sparkContext.register(acc)
+      acc match {
+        case lastAttemptAcc: LastAttemptAggregatingAccumulator =>
+          lastAttemptAcc.initializeLastAttemptAccumulator()
+        case _ =>
+      }
+
+      def consume(ids: Iterator[Long]): Unit = {
+        val row = new SpecificInternalRow(Seq(LongType, StringType))
+        ids.foreach { id =>
+          // Create the new row values.
+          row.setLong(0, id)
+          row.update(1, UTF8String.fromString(f"val_$id%06d"))
+
+          // Update the accumulator
+          acc.add(row)
+        }
       }
-    }
 
-    // Run job 1
-    spark.sparkContext
-      .range(0, 1000, 1, 8)
-      .foreachPartition(consume)
-    assert(checkResult(
-      acc.value,
-      InternalRow(
+      // Run job 1
+      spark.sparkContext
+        .range(0, 1000, 1, 8)
+        .foreachPartition(consume)
+      val expectedAfterJob1 = InternalRow(
         500.5d,
         509500L,
         str("val_000000"),
@@ -173,17 +228,27 @@ class AggregatingAccumulatorSuite
         new GenericArrayData(Seq(0L, 1L, 2L)),
         0,
         7,
-        0),
-      acc.schema,
-      false))
-
-    // Run job 2
-    spark.sparkContext
-      .range(1000, 1200, 1, 8)
-      .foreachPartition(consume)
-    assert(checkResult(
-      acc.value,
-      InternalRow(
+        0)
+      assert(checkResult(
+        acc.value,
+        expectedAfterJob1,
+        acc.schema,
+        false))
+      acc match {
+        case lastAttemptAcc: LastAttemptAggregatingAccumulator =>
+          assert(checkResult(
+            lastAttemptAcc.lastAttemptValueForAllRDDs().get,
+            expectedAfterJob1,
+            acc.schema,
+            false))
+        case _ =>
+      }
+
+      // Run job 2
+      spark.sparkContext
+        .range(1000, 1200, 1, 8)
+        .foreachPartition(consume)
+      val expectedAfterJob2 = InternalRow(
         600.5d,
         731400L,
         str("val_000000"),
@@ -192,8 +257,201 @@ class AggregatingAccumulatorSuite
         new GenericArrayData(Seq(0L, 1L, 2L, 3L, 4L, 5L)),
         0,
         7,
-        0),
+        0)
+      assert(checkResult(
+        acc.value,
+        expectedAfterJob2,
+        acc.schema,
+        false))
+      acc match {
+        case lastAttemptAcc: LastAttemptAggregatingAccumulator =>
+          assert(checkResult(
+            lastAttemptAcc.lastAttemptValueForAllRDDs().get,
+            expectedAfterJob2,
+            acc.schema,
+            false))
+        case _ =>
+      }
+    }
+  }
+
+  // 
---------------------------------------------------------------------------
+  // Tests exclusive to the last-attempt accumulator.
+  // 
---------------------------------------------------------------------------
+
+  test("last attempt direct driver value is copied") {
+    val acc = registeredLastAttempt(Seq(sum(a) as "sum_a"), Seq(a))
+
+    val row = new SpecificInternalRow(Seq(LongType))
+    row.setLong(0, 1L)
+    acc.add(row)
+    val firstLastAttemptValue = acc.lastAttemptValueForHighestRDDId().get
+    checkResult(firstLastAttemptValue, InternalRow(1L), acc.schema, false)
+
+    row.setLong(0, 2L)
+    acc.add(row)
+    checkResult(acc.lastAttemptValueForHighestRDDId().get, InternalRow(3L), 
acc.schema, false)
+    checkResult(firstLastAttemptValue, InternalRow(1L), acc.schema, false)
+  }
+
+  test("last attempt partial merge combines aggregate buffers") {
+    val acc = registeredLastAttempt(Seq(sum(a) as "sum_a", max(b) as "max_b"), 
Seq(a, b))
+
+    val rdd = sparkContext.parallelize(Seq.empty[Int], 2)
+    val properties = new Properties
+
+    acc.mergeLastAttempt(
+      taskUpdate(acc, InternalRow(1L, str("a")), InternalRow(2L, str("b"))),
+      rdd,
+      taskInfo(partitionId = 0),
+      stageId = 0,
+      stageAttemptId = 0,
+      properties)
+    acc.mergeLastAttempt(
+      taskUpdate(acc, InternalRow(10L, str("z")), InternalRow(-5L, str("c"))),
+      rdd,
+      taskInfo(partitionId = 1),
+      stageId = 0,
+      stageAttemptId = 0,
+      properties)
+
+    checkResult(
+      acc.lastAttemptValueForRDDId(rdd.id).get,
+      InternalRow(8L, str("z")),
       acc.schema,
-      false))
+      false)
+  }
+
+  test("last attempt invalidates on schema mismatch") {
+    val acc = registeredLastAttempt(Seq(sum(a) as "sum_a"), Seq(a))
+
+    // A task accumulator with a different schema is not mergeable, so the 
merge must bail out.
+    val mismatched = AggregatingAccumulator.lastAttempt(
+      Seq(sum(a) as "sum_a", max(b) as "max_b"),
+      Seq(a, b))
+    mismatched.add(InternalRow(1L, str("a")))
+
+    val rdd = sparkContext.parallelize(Seq.empty[Int], 1)
+    acc.mergeLastAttempt(
+      mismatched,
+      rdd,
+      taskInfo(partitionId = 0),
+      stageId = 0,
+      stageAttemptId = 0,
+      new Properties)
+
+    assert(!acc.getValid)
+    assert(acc.lastAttemptValueForAllRDDs().isEmpty)
+    assert(acc.lastAttemptValueForRDDId(rdd.id).isEmpty)
+  }
+
+  test("last attempt invalidates when driver and task updates are mixed") {
+    val acc = registeredLastAttempt(Seq(sum(a) as "sum_a"), Seq(a))
+
+    val rdd = sparkContext.parallelize(Seq.empty[Int], 1)
+    acc.mergeLastAttempt(
+      taskUpdate(acc, InternalRow(5L)),
+      rdd,
+      taskInfo(partitionId = 0),
+      stageId = 0,
+      stageAttemptId = 0,
+      new Properties)
+    checkResult(acc.lastAttemptValueForAllRDDs().get, InternalRow(5L), 
acc.schema, false)
+
+    // A direct driver update on top of task updates cannot be reasoned about, 
so it invalidates.
+    val row = new SpecificInternalRow(Seq(LongType))
+    row.setLong(0, 1L)
+    acc.add(row)
+
+    assert(!acc.getValid)
+    assert(acc.lastAttemptValueForAllRDDs().isEmpty)
+  }
+
+  test("last attempt value for highest RDD id selects the latest RDD") {
+    val acc = registeredLastAttempt(Seq(sum(a) as "sum_a"), Seq(a))
+
+    val rdd1 = sparkContext.parallelize(Seq.empty[Int], 1)
+    val rdd2 = sparkContext.parallelize(Seq.empty[Int], 1)
+    assert(rdd2.id > rdd1.id)
+
+    acc.mergeLastAttempt(
+      taskUpdate(acc, InternalRow(3L)),
+      rdd1,
+      taskInfo(partitionId = 0),
+      stageId = 0,
+      stageAttemptId = 0,
+      new Properties)
+    acc.mergeLastAttempt(
+      taskUpdate(acc, InternalRow(10L)),
+      rdd2,
+      taskInfo(partitionId = 0),
+      stageId = 1,
+      stageAttemptId = 0,
+      new Properties)
+
+    // Highest RDD id aggregates only the latest RDD.
+    checkResult(acc.lastAttemptValueForHighestRDDId().get, InternalRow(10L), 
acc.schema, false)
+    // All RDDs aggregate both.
+    checkResult(acc.lastAttemptValueForAllRDDs().get, InternalRow(13L), 
acc.schema, false)
+    // Narrowing to the earlier RDD returns only its value.
+    checkResult(acc.lastAttemptValueForRDDId(rdd1.id).get, InternalRow(3L), 
acc.schema, false)
+  }
+
+  test("last attempt skips uncomputed partitions") {
+    val acc = registeredLastAttempt(Seq(sum(a) as "sum_a"), Seq(a))
+
+    // The RDD has 3 partitions but only 0 and 2 report values (e.g. early 
stop in take/limit).
+    val rdd = sparkContext.parallelize(Seq.empty[Int], 3)
+    acc.mergeLastAttempt(
+      taskUpdate(acc, InternalRow(4L)),
+      rdd,
+      taskInfo(partitionId = 0),
+      stageId = 0,
+      stageAttemptId = 0,
+      new Properties)
+    acc.mergeLastAttempt(
+      taskUpdate(acc, InternalRow(6L)),
+      rdd,
+      taskInfo(partitionId = 2),
+      stageId = 0,
+      stageAttemptId = 0,
+      new Properties)
+
+    // Partition 1 never reported, so only partitions 0 and 2 contribute.
+    checkResult(acc.lastAttemptValueForRDDId(rdd.id).get, InternalRow(10L), 
acc.schema, false)
+  }
+
+  test("last attempt uses the latest stage attempt for a partition") {
+    val acc = registeredLastAttempt(Seq(sum(a) as "sum_a"), Seq(a))
+
+    val rdd = sparkContext.parallelize(Seq.empty[Int], 1)
+    acc.mergeLastAttempt(
+      taskUpdate(acc, InternalRow(3L)),
+      rdd,
+      taskInfo(partitionId = 0),
+      stageId = 0,
+      stageAttemptId = 0,
+      new Properties)
+    checkResult(acc.lastAttemptValueForRDDId(rdd.id).get, InternalRow(3L), 
acc.schema, false)
+
+    // A later stage attempt recomputes the same partition and replaces its 
value.
+    acc.mergeLastAttempt(
+      taskUpdate(acc, InternalRow(100L)),
+      rdd,
+      taskInfo(partitionId = 0),
+      stageId = 0,
+      stageAttemptId = 1,
+      new Properties)
+    checkResult(acc.lastAttemptValueForRDDId(rdd.id).get, InternalRow(100L), 
acc.schema, false)
+
+    // An older attempt arriving late is discarded.
+    acc.mergeLastAttempt(
+      taskUpdate(acc, InternalRow(7L)),
+      rdd,
+      taskInfo(partitionId = 0),
+      stageId = 0,
+      stageAttemptId = 0,
+      new Properties)
+    checkResult(acc.lastAttemptValueForRDDId(rdd.id).get, InternalRow(100L), 
acc.schema, false)
   }
 }
diff --git 
a/sql/core/src/test/scala/org/apache/spark/sql/execution/metric/MetricsFailureInjectionSuite.scala
 
b/sql/core/src/test/scala/org/apache/spark/sql/execution/metric/MetricsFailureInjectionSuite.scala
index 06b0d64944e6..33da022f7aa3 100644
--- 
a/sql/core/src/test/scala/org/apache/spark/sql/execution/metric/MetricsFailureInjectionSuite.scala
+++ 
b/sql/core/src/test/scala/org/apache/spark/sql/execution/metric/MetricsFailureInjectionSuite.scala
@@ -16,15 +16,20 @@
  */
 package org.apache.spark.sql.execution.metric
 
+import java.util.concurrent.atomic.AtomicBoolean
+
+import scala.collection.mutable.ArrayBuffer
 import scala.util.Random
 
 import org.apache.spark.SparkException
 import org.apache.spark.internal.config
-import org.apache.spark.sql.{Column, Dataset}
+import org.apache.spark.sql.{Column, Dataset, Observation, Row}
+import org.apache.spark.sql.execution.QueryExecution
 import org.apache.spark.sql.execution.adaptive.{AQETestHelper, 
DisableAdaptiveExecutionSuite}
-import org.apache.spark.sql.functions.udf
+import org.apache.spark.sql.functions.{approx_count_distinct, collect_list, 
collect_set, count, lit, max, min, size, sort_array, sum, udf}
 import org.apache.spark.sql.internal.SQLConf
 import org.apache.spark.sql.test.SharedSparkSession
+import org.apache.spark.sql.util.QueryExecutionListener
 
 class MetricsFailureInjectionSuite
   extends SharedSparkSession
@@ -299,6 +304,80 @@ class MetricsFailureInjectionSuite
     }
   }
 
+  test("Observation metrics select attempts according to legacy config") {
+    withTable("test_table") {
+      setUpTestTable("test_table")
+      withSparkContextConf(
+          config.Tests.INJECT_SHUFFLE_FETCH_FAILURES.key -> "true") {
+        Seq(false, true).foreach { aggregateAllAttempts =>
+          withSQLConf(
+              SQLConf.LEGACY_OBSERVE_METRICS_AGGREGATE_ALL_ATTEMPTS.key ->
+                aggregateAllAttempts.toString) {
+            val observation = Observation("stage1")
+            val observed = spark.read.table("test_table")
+              .observe(observation, count(lit(1)).as("rows"))
+            val finalDf = 
observed.groupBy("low_cardinality_col").count().as[(Int, Long)]
+            val result = finalDf.collect()
+
+            assert(result.toMap === (0 until 5).map(v => (v, 300 / 5)).toMap)
+            if (aggregateAllAttempts) {
+              assert(observation.get("rows").asInstanceOf[Long] > 300L)
+            } else {
+              assert(observation.get === Map("rows" -> 300L))
+            }
+          }
+        }
+      }
+    }
+  }
+
+  test("Observation metrics reset last-attempt state after failure injection") 
{
+    val includeRows = new AtomicBoolean(true)
+    val includeRow = udf { _: Long => includeRows.get() }
+    val metricMaps = ArrayBuffer.empty[Map[String, Row]]
+    val listener = new QueryExecutionListener {
+      override def onSuccess(funcName: String, qe: QueryExecution, duration: 
Long): Unit = {
+        if (qe.observedMetrics.nonEmpty) {
+          metricMaps += qe.observedMetrics
+        }
+      }
+
+      override def onFailure(funcName: String, qe: QueryExecution, exception: 
Exception): Unit = {
+        // No-op
+      }
+    }
+
+    withTable("test_table") {
+      setUpTestTable("test_table")
+      withSparkContextConf(
+          config.Tests.INJECT_SHUFFLE_FETCH_FAILURES.key -> "true") {
+        val observed = spark.read.table("test_table")
+          .filter(includeRow($"id"))
+          .observe(name = "stage1", count(lit(1)).as("rows"))
+        val finalDf = observed.groupBy("low_cardinality_col").count().as[(Int, 
Long)]
+
+        spark.listenerManager.register(listener)
+        try {
+          val result = finalDf.collect()
+          sparkContext.listenerBus.waitUntilEmpty()
+          assert(result.toMap === (0 until 5).map(v => (v, 300 / 5)).toMap)
+          assert(metricMaps.size === 1)
+          assert(metricMaps.head("stage1") === Row(300L))
+          metricMaps.clear()
+
+          // Reuse the same Dataset while making the second execution produce 
no rows.
+          includeRows.set(false)
+          assert(finalDf.collect().isEmpty)
+          sparkContext.listenerBus.waitUntilEmpty()
+          assert(metricMaps.size === 1)
+          assert(metricMaps.head("stage1") === Row(0L))
+        } finally {
+          spark.listenerManager.unregister(listener)
+        }
+      }
+    }
+  }
+
   for {
     injectFailure <- BOOLEAN_DOMAIN
   } test(s"Non-deterministic stage block failure injection - 
injectFailure=$injectFailure") {
@@ -686,4 +765,195 @@ class MetricsFailureInjectionSuite
       }
     }
   }
+
+  // Observed metrics are computed by the last-attempt aggregating 
accumulator. This exercises
+  // every aggregate-buffer flavor when a shuffle-fetch failure forces the 
observed (leaf) stage
+  // to recompute:
+  //  - declarative aggregates (count / sum / min / max),
+  //  - imperative, non-typed aggregate (approx_count_distinct -> 
HyperLogLogPlusPlus),
+  //  - typed imperative aggregates (collect_set and collect_list).
+  // Regardless of the recompute, the observed values must equal the 
single-attempt values,
+  // proving the last-attempt accumulator's partial-merge and buffer 
snapshotting are correct for
+  // imperative and typed-imperative buffers, not just simple long counters.
+  // collect_list is the strongest guard here: unlike collect_set / 
approx_count_distinct (which
+  // are idempotent under re-merge, so merging a superseded attempt would 
still yield the right
+  // set/estimate), a collect_list would double in length if a superseded 
attempt were not
+  // discarded, so size(collect_list) == 300 deterministically proves 
last-attempt selection for a
+  // typed-imperative buffer.
+  for {
+    injectFailure <- BOOLEAN_DOMAIN
+  } test(s"Observation metrics with all aggregate buffer types survive failure 
injection - " +
+      s"injectFailure=$injectFailure") {
+    val observation = Observation("mixed_aggregate_buffers")
+
+    withTable("test_table") {
+      setUpTestTable("test_table")
+      withSparkContextConf(
+          config.Tests.INJECT_SHUFFLE_FETCH_FAILURES.key -> 
injectFailure.toString) {
+        val observed = spark.read.table("test_table")
+          .observe(
+            observation,
+            count(lit(1)).as("rows"),
+            sum($"low_cardinality_col").as("sum_lcc"),
+            min($"id").as("min_id"),
+            max($"id").as("max_id"),
+            
approx_count_distinct($"low_cardinality_col").as("approx_distinct_lcc"),
+            sort_array(collect_set($"low_cardinality_col")).as("distinct_lcc"),
+            size(collect_list($"low_cardinality_col")).as("list_size_lcc"))
+        val finalDf = observed.groupBy("low_cardinality_col").count().as[(Int, 
Long)]
+        val result = finalDf.collect()
+
+        assert(result.toMap === (0 until 5).map(v => (v, 300 / 5)).toMap)
+
+        // low_cardinality_col = id % 5 over ids 0..299: each residue in 
{0,1,2,3,4} appears 60
+        // times, so sum = 60 * (0+1+2+3+4) = 600, and the distinct set is 
{0,1,2,3,4}.
+        val metrics = observation.get
+        assert(metrics("rows") === 300L)
+        assert(metrics("sum_lcc") === 600L)
+        assert(metrics("min_id") === 0L)
+        assert(metrics("max_id") === 299L)
+        assert(metrics("approx_distinct_lcc") === 5L)
+        assert(metrics("distinct_lcc") === Seq(0, 1, 2, 3, 4))
+        // 300 rows total; would be 600 if a superseded attempt's collect_list 
were not discarded.
+        assert(metrics("list_size_lcc") === 300)
+      }
+    }
+  }
+
+  // Observed metrics are produced by the last-attempt aggregating 
accumulator. A forced AQE
+  // cancellation re-runs the map stage feeding the shuffle, creating a new 
RDD for the replanned
+  // stage - the canonical case the last-attempt tracking is designed for. 
Regardless of the
+  // replan, the observed values must reflect only the last attempt, for every 
aggregate-buffer
+  // flavor (declarative / imperative / typed-imperative). AQE must be enabled 
for the forced
+  // cancellation to take effect (it is a no-op otherwise), so this scenario 
is AQE-only.
+  test("Observation metrics AQE cancellation injection") {
+    withSQLConf(SQLConf.ADAPTIVE_EXECUTION_ENABLED.key -> "true") {
+      val stage1Metric = SQLMetrics.createMetric(spark.sparkContext, "stage 1 
counter")
+
+      def runQueryWithObservation(triggerMetrics: SQLMetric*): Unit = {
+        assert(stage1Metric.value === 0)
+        val observation = Observation("aqe_obs")
+        withTable("test_table") {
+          setUpTestTable("test_table")
+          AQETestHelper.withForcedCancellation(triggerMetrics: _*) {
+            val stage1MetricsExpr = incrementMetrics(Seq(stage1Metric))
+            val stage1 = spark.read.table("test_table")
+              .observe(
+                observation,
+                count(lit(1)).as("rows"),
+                sum($"low_cardinality_col").as("sum_lcc"),
+                
approx_count_distinct($"low_cardinality_col").as("approx_distinct_lcc"),
+                
sort_array(collect_set($"low_cardinality_col")).as("distinct_lcc"))
+              .filter(Column(stage1MetricsExpr))
+            val finalDf = 
stage1.groupBy("low_cardinality_col").count().as[(Int, Long)]
+            val result = finalDf.collect()
+            assert(result.toMap === (0 until 5).map(v => (v, 300 / 5)).toMap)
+
+            // low_cardinality_col = id % 5 over ids 0..299: each residue 
appears 60 times, so
+            // sum = 60 * (0+1+2+3+4) = 600 and the distinct set is 
{0,1,2,3,4}.
+            val metrics = observation.get
+            assert(metrics("rows") === 300L)
+            assert(metrics("sum_lcc") === 600L)
+            assert(metrics("approx_distinct_lcc") === 5L)
+            assert(metrics("distinct_lcc") === Seq(0, 1, 2, 3, 4))
+            stage1Metric.reset()
+          }
+        }
+      }
+
+      // Case 1: No forced replanning (single-attempt baseline).
+      runQueryWithObservation()
+      // Case 2: Replan on stage1Metric, so the map stage feeding the shuffle 
re-runs with a new
+      // RDD; the observed metrics must still reflect only the last attempt.
+      runQueryWithObservation(stage1Metric)
+    }
+  }
+
+  // The other observation failure tests all observe the leaf scan, so only a 
*downstream* stage
+  // recomputes while the observed operator itself runs once. Here the 
observation sits on a
+  // non-leaf operator (the output of a join that feeds a shuffle), so the 
observed operator's own
+  // RDD is the one re-run when the downstream fetch failure fires. This 
exercises per-RDD
+  // last-attempt selection for the aggregate buffer on a recomputed non-leaf 
stage, across every
+  // aggregate-buffer flavor, rather than only on a leaf scan.
+  test("Observation metrics on a non-leaf stage survive failure injection") {
+    val observation = Observation("non_leaf_obs")
+
+    withTable("primary_table", "secondary_table") {
+      setUpTestTable("primary_table")
+      setUpTestTable("secondary_table")
+      withSparkContextConf(
+          config.Tests.INJECT_SHUFFLE_FETCH_FAILURES.key -> "true") {
+        val joined = spark.read.table("primary_table")
+          .join(
+            spark.read.table("secondary_table"),
+            usingColumn = "id",
+            joinType = "fullOuter")
+          .observe(
+            observation,
+            count(lit(1)).as("rows"),
+            sum("primary_table.low_cardinality_col").as("sum_lcc"),
+            
approx_count_distinct("primary_table.low_cardinality_col").as("approx_distinct_lcc"),
+            
sort_array(collect_set("primary_table.low_cardinality_col")).as("distinct_lcc"))
+        val finalDf = 
joined.groupBy("primary_table.low_cardinality_col").count().as[(Int, Long)]
+        val result = finalDf.collect()
+        assert(result.toMap === (0 until 5).map(v => (v, 300 / 5)).toMap)
+
+        // A full-outer join of the two identical 300-row tables on the unique 
`id` yields 300
+        // rows, so the observed values match the single-attempt leaf values: 
each residue in
+        // {0,1,2,3,4} appears 60 times, so sum = 60 * (0+1+2+3+4) = 600.
+        val metrics = observation.get
+        assert(metrics("rows") === 300L)
+        assert(metrics("sum_lcc") === 600L)
+        assert(metrics("approx_distinct_lcc") === 5L)
+        assert(metrics("distinct_lcc") === Seq(0, 1, 2, 3, 4))
+      }
+    }
+  }
+
+  // The failure suite otherwise has a single observe per query, and 
DataFrameCallbackSuite has
+  // multi-observe queries but no retries. This combines them: two observation 
nodes at different
+  // stages (a leaf scan and a non-leaf join output), both of which recompute 
under fetch-failure
+  // injection. Each observation is backed by its own CollectMetricsExec / 
last-attempt
+  // accumulator, so this checks that they independently report their own 
correct last-attempt
+  // value rather than leaking into one another.
+  test("Multiple observation points survive failure injection") {
+    val leafObs = Observation("leaf_obs")
+    val nonLeafObs = Observation("non_leaf_obs")
+
+    withTable("primary_table", "secondary_table") {
+      setUpTestTable("primary_table")
+      setUpTestTable("secondary_table")
+      withSparkContextConf(
+          config.Tests.INJECT_SHUFFLE_FETCH_FAILURES.key -> "true") {
+        val leaf = spark.read.table("primary_table")
+          .observe(
+            leafObs,
+            count(lit(1)).as("rows"),
+            sort_array(collect_set("low_cardinality_col")).as("distinct_lcc"))
+        val joined = leaf
+          .join(
+            spark.read.table("secondary_table"),
+            usingColumn = "id",
+            joinType = "fullOuter")
+          .observe(
+            nonLeafObs,
+            count(lit(1)).as("rows"),
+            sum("primary_table.low_cardinality_col").as("sum_lcc"))
+        val finalDf = 
joined.groupBy("primary_table.low_cardinality_col").count().as[(Int, Long)]
+        val result = finalDf.collect()
+        assert(result.toMap === (0 until 5).map(v => (v, 300 / 5)).toMap)
+
+        // The leaf scan and the join output each recompute, but every 
observation reports only its
+        // last successful attempt: 300 rows on both, distinct {0,1,2,3,4} on 
the leaf, and
+        // sum = 60 * (0+1+2+3+4) = 600 on the join output.
+        val leafMetrics = leafObs.get
+        assert(leafMetrics("rows") === 300L)
+        assert(leafMetrics("distinct_lcc") === Seq(0, 1, 2, 3, 4))
+
+        val nonLeafMetrics = nonLeafObs.get
+        assert(nonLeafMetrics("rows") === 300L)
+        assert(nonLeafMetrics("sum_lcc") === 600L)
+      }
+    }
+  }
 }
diff --git 
a/sql/core/src/test/scala/org/apache/spark/sql/util/DataFrameCallbackSuite.scala
 
b/sql/core/src/test/scala/org/apache/spark/sql/util/DataFrameCallbackSuite.scala
index cccd0a87d4cc..2912917a7be3 100644
--- 
a/sql/core/src/test/scala/org/apache/spark/sql/util/DataFrameCallbackSuite.scala
+++ 
b/sql/core/src/test/scala/org/apache/spark/sql/util/DataFrameCallbackSuite.scala
@@ -19,6 +19,7 @@ package org.apache.spark.sql.util
 
 import java.lang.{Long => JLong}
 import java.util.concurrent.{CopyOnWriteArrayList, CountDownLatch, TimeUnit}
+import java.util.concurrent.atomic.AtomicBoolean
 
 import scala.collection.mutable.ArrayBuffer
 import scala.jdk.CollectionConverters._
@@ -401,6 +402,42 @@ class DataFrameCallbackSuite extends SharedSparkSession
     }
   }
 
+  test("observed metrics reset last-attempt state between reused executions") {
+    val includeRows = new AtomicBoolean(true)
+    val includeRow = udf { _: Long => includeRows.get() }
+    val df = spark.range(0, 10, 1, 2)
+      .filter(includeRow($"id"))
+      .observe(name = "my_event", count(lit(1)).as("rows"))
+
+    val metricMaps = ArrayBuffer.empty[Map[String, Row]]
+    val listener = new QueryExecutionListener {
+      override def onSuccess(funcName: String, qe: QueryExecution, duration: 
Long): Unit = {
+        metricMaps += qe.observedMetrics
+      }
+
+      override def onFailure(funcName: String, qe: QueryExecution, exception: 
Exception): Unit = {
+        // No-op
+      }
+    }
+    spark.listenerManager.register(listener)
+    try {
+      assert(df.collect().length === 10)
+      sparkContext.listenerBus.waitUntilEmpty()
+      assert(metricMaps.size === 1)
+      assert(metricMaps.head("my_event") === Row(10L))
+      metricMaps.clear()
+
+      // Reuse the same Dataset while making the second execution produce no 
rows.
+      includeRows.set(false)
+      assert(df.collect().isEmpty)
+      sparkContext.listenerBus.waitUntilEmpty()
+      assert(metricMaps.size === 1)
+      assert(metricMaps.head("my_event") === Row(0L))
+    } finally {
+      spark.listenerManager.unregister(listener)
+    }
+  }
+
   test("SPARK-50581: support observe with udaf") {
     withUserDefinedFunction(("someUdaf", true)) {
       spark.udf.register("someUdaf", functions.udaf(new Aggregator[JLong, 
JLong, JLong] {


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

Reply via email to