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

Yicong-Huang 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 202b4d6e6eb1 [SPARK-57324][SQL][UDF] Surface external UDF terminal 
metrics
202b4d6e6eb1 is described below

commit 202b4d6e6eb11bf75ec8f539e460734f9c073c79
Author: Timothy Meehan <[email protected]>
AuthorDate: Fri Oct 9 17:38:05 2026 +0000

    [SPARK-57324][SQL][UDF] Surface external UDF terminal metrics
    
    ### What changes were proposed in this pull request?
    
    Add a typed `ExecutionMetrics` protobuf to the external UDF worker 
protocol's `FinishResponse` and `CancelResponse`, as a new `execution_metrics` 
field at the end of each message. The existing free-form `metrics` map stays at 
field 1, so workers and engines built against either version keep working 
together.
    
    The message defines optional fields for row, batch, and payload-byte counts 
plus wall-clock and CPU timings, and documents what each field measures: its 
direction, unit, and interval, including which timers are wait times. It also 
carries two per-stream maxima -- the worker's slowest and most CPU-intensive 
data unit -- which the engine cannot recover from the summed timings. Each 
terminal response carries one cumulative snapshot for its `Execute` stream: 
final totals on finish and par [...]
    
    Create corresponding SQL metrics and aggregate the fields present in the 
terminal snapshot when a worker session closes. Values outside Spark's signed 
`Long` metric range are ignored. Count metrics start at zero, so a count that a 
worker omits adds nothing to the SQL UI total. Worker session creation is a 
separate `createUDFWorkerSession` hook, so tests can replace the worker while 
running the production close-and-record lifecycle.
    
    ### Why are the changes needed?
    
    The protocol currently exposes terminal metrics as string keys and values, 
and `ExternalUDFExec` discards them. A typed protobuf gives worker 
implementations generated, type-safe accessors and gives the engine a stable 
contract for exposing equivalent measurements consistently.
    
    ### Does this PR introduce _any_ user-facing change?
    
    Yes, within unreleased `master`. External UDF physical operators expose 
typed worker execution metrics in the SQL UI and event data. Spark reads these 
metrics from the new `execution_metrics` field; the `metrics` map is unchanged 
and remains worker-defined.
    
    ### How was this patch tested?
    
    Extended `ExecuteExternalUDFExecSuite` to cover:
    
    - typed snapshots from finish and acknowledged cancellation, recorded 
through `ExternalUDFExec`'s own session-close listener; and
    - missing versus explicitly measured zero values, including that an omitted 
count reports no negative value for the SQL UI to sum.
    
    Ran:
    
    ```text
    build/sbt 'udf-worker-core/test' 'udf-worker-grpc/test' \
      'sql/testOnly 
org.apache.spark.sql.execution.externalUDF.ExecuteExternalUDFExecSuite'
    ```
    
    All tests passed: 59 in `udf-worker-core`, 60 in `udf-worker-grpc`, and 18 
in `ExecuteExternalUDFExecSuite`.
    
    ### Was this patch authored or co-authored using generative AI tooling?
    
    Generated-by: OpenAI Codex 0.147.0
    Generated-by: Isaac (Claude Opus 5.5)
    
    This pull request and its description were written by Isaac.
    
    Closes #59111 from tdcmeehan/tdcmeehan/external-udf-terminal-metrics.
    
    Authored-by: Timothy Meehan <[email protected]>
    Signed-off-by: Yicong-Huang <[email protected]>
---
 .../execution/externalUDF/ExternalUDFExec.scala    | 132 ++++++++++++++---
 .../externalUDF/ExecuteExternalUDFExecSuite.scala  | 161 +++++++++++++++++++--
 .../spark/udf/worker/core/WorkerSession.scala      |   4 +-
 .../org/apache/spark/udf/worker/udf_message.proto  |  96 +++++++++++-
 4 files changed, 352 insertions(+), 41 deletions(-)

diff --git 
a/sql/core/src/main/scala/org/apache/spark/sql/execution/externalUDF/ExternalUDFExec.scala
 
b/sql/core/src/main/scala/org/apache/spark/sql/execution/externalUDF/ExternalUDFExec.scala
index db88ef90d62b..bbfa3e331493 100644
--- 
a/sql/core/src/main/scala/org/apache/spark/sql/execution/externalUDF/ExternalUDFExec.scala
+++ 
b/sql/core/src/main/scala/org/apache/spark/sql/execution/externalUDF/ExternalUDFExec.scala
@@ -17,13 +17,13 @@
 
 package org.apache.spark.sql.execution.externalUDF
 
-import org.apache.spark.{SparkEnv, TaskContext}
+import org.apache.spark.{SparkContext, SparkEnv, TaskContext}
 import org.apache.spark.annotation.Experimental
 import org.apache.spark.sql.catalyst.InternalRow
 import org.apache.spark.sql.execution.UnaryExecNode
-import org.apache.spark.sql.execution.metric.SQLMetric
-import org.apache.spark.udf.worker.UDFWorkerSpecification
-import org.apache.spark.udf.worker.core.{WorkerSecurityScope, WorkerSession}
+import org.apache.spark.sql.execution.metric.{SQLMetric, SQLMetrics}
+import org.apache.spark.udf.worker.{ExecutionMetrics, UDFWorkerSpecification}
+import org.apache.spark.udf.worker.core.{Termination, WorkerSecurityScope, 
WorkerSession}
 
 /**
  * :: Experimental ::
@@ -48,9 +48,8 @@ trait ExternalUDFExec extends UnaryExecNode {
   // Metrics
   // 
---------------------------------------------------------------------------
 
-  protected def externalUdfMetrics: Map[String, SQLMetric] = Map(
-    // TODO [SPARK-57324]: Emit the correct metrics here
-  )
+  protected val externalUdfMetrics: Map[String, SQLMetric] =
+    ExternalUDFMetrics.create(sparkContext)
 
   override lazy val metrics: Map[String, SQLMetric] = externalUdfMetrics
 
@@ -59,25 +58,23 @@ trait ExternalUDFExec extends UnaryExecNode {
   // 
---------------------------------------------------------------------------
 
   /**
-   * Creates a [[WorkerSession]] via [[SparkEnv#getExternalUDFDispatcher]].
-   * Finalizes the session on task completion (which fires on both success and
-   * failure). [[WorkerSession#close]] is the single finalizer: it fetches the
+   * Creates a [[WorkerSession]] with [[createUDFWorkerSession]] and finalizes 
it
+   * on task completion (which fires on both success and failure).
+   * [[WorkerSession#close]] is the single finalizer: it fetches the
    * `FinishResponse` if processing completed, or cancels anything still in
    * flight and waits for the `CancelResponse`. For the UDF sessions used here,
    * exhausting the data iterator completes execution and surfaces execution or
-   * finish errors. Therefore, `close` only cleans up the session and its 
returned
-   * termination does not determine whether the Spark task succeeds. The 
provided
-   * function receives the session and must return the result iterator. It may 
use
-   * the session but MUST NOT close it.
+   * finish errors. Therefore, `close` cleans up the session, and the 
termination
+   * it returns is used only for metrics; it does not determine whether the 
Spark
+   * task succeeds. The provided function receives the session and must return 
the
+   * result iterator. It may use the session but MUST NOT close it.
    */
   protected def withUDFWorkerSession(
       taskContext: TaskContext,
       securityScope: Option[WorkerSecurityScope] = None)(
       f: WorkerSession => Iterator[InternalRow]
   ): Iterator[InternalRow] = {
-    val dispatcher = SparkEnv.get.getExternalUDFDispatcher(
-      workerSpec)
-    val session = dispatcher.createSession(securityScope)
+    val session = createUDFWorkerSession(securityScope)
 
     // Finalize the session when the task ends. The completion listener fires 
on
     // both success and failure, and close() is the single finalizer that
@@ -99,11 +96,106 @@ trait ExternalUDFExec extends UnaryExecNode {
     //    failure has already surfaced through the result iterator.
     //
     // For these UDF sessions, exhausting the data iterator covers execution 
and
-    // surfaces its errors. close() only cleans up protocol state and releases 
or
-    // invalidates the worker handle, so its return value is intentionally 
ignored.
+    // surfaces its errors. close() cleans up protocol state and releases or
+    // invalidates the worker handle; a clean terminal response also carries 
the
+    // execution's final or partial metrics.
     //
-    taskContext.addTaskCompletionListener[Unit](_ => session.close())
+    taskContext.addTaskCompletionListener[Unit] { _ =>
+      recordTerminalMetrics(session.close())
+    }
 
     f(session)
   }
+
+  protected def createUDFWorkerSession(
+      securityScope: Option[WorkerSecurityScope]): WorkerSession = {
+    
SparkEnv.get.getExternalUDFDispatcher(workerSpec).createSession(securityScope)
+  }
+
+  private def recordTerminalMetrics(termination: Termination): Unit = {
+    val reported = termination match {
+      case Termination.Finished(response) if response.hasExecutionMetrics =>
+        Some(response.getExecutionMetrics)
+      case Termination.Cancelled(response) if response.hasExecutionMetrics =>
+        Some(response.getExecutionMetrics)
+      case _ => None
+    }
+    reported.foreach(ExternalUDFMetrics.update(metrics, _))
+  }
+}
+
+private[externalUDF] object ExternalUDFMetrics {
+  private val sizeMetrics = Map(
+    "bytesIn" -> "data received by the external UDF worker",
+    "bytesOut" -> "data returned by the external UDF worker")
+
+  private val countMetrics = Map(
+    "rowsIn" -> "rows received by the external UDF worker",
+    "rowsOut" -> "rows returned by the external UDF worker",
+    "batchesIn" -> "batches received by the external UDF worker",
+    "batchesOut" -> "batches returned by the external UDF worker")
+
+  private val timingMetrics = Map(
+    "initWallNanos" -> "external UDF worker initialization time",
+    "processingWallNanos" -> "external UDF worker processing time",
+    "receiveWallNanos" -> "time the external UDF worker waited for requests",
+    "sendWallNanos" -> "time the external UDF worker was blocked sending 
responses",
+    "workWallNanos" -> "external UDF worker execution time",
+    "workCpuNanos" -> "external UDF worker CPU time",
+    "finishWallNanos" -> "external UDF worker finish time")
+
+  // Per-stream maxima rather than totals. Like peakMemory, the figure to read 
is the
+  // per-task max in the UI's (min, med, max) breakdown, not the aggregated 
total (a sum
+  // of per-task maxima). Formatted as nano timing metrics.
+  private val peakMetrics = Map(
+    "maxWorkWallNanos" -> "longest data unit executed by the external UDF 
worker",
+    "maxWorkCpuNanos" -> "most CPU-intensive data unit executed by the 
external UDF worker")
+
+  def create(sc: SparkContext): Map[String, SQLMetric] = {
+    sizeMetrics.map { case (name, description) =>
+      name -> SQLMetrics.createSizeMetric(sc, description)
+    } ++ countMetrics.map { case (name, description) =>
+      name -> SQLMetrics.createMetric(sc, description)
+    } ++ (timingMetrics ++ peakMetrics).map { case (name, description) =>
+      name -> SQLMetrics.createNanoTimingMetric(sc, description)
+    }
+  }
+
+  def update(target: Map[String, SQLMetric], reported: ExecutionMetrics): Unit 
= {
+    def addIfPresent(name: String, present: Boolean, value: => Long): Unit = {
+      if (present) {
+        target(name) += value
+      }
+    }
+
+    // Peak metrics are per-stream maxima, so a task running several worker 
sessions keeps
+    // the largest rather than summing. Mirrors pythonPeakPickledBatchBytes in
+    // BatchEvalPythonExec; set() overwrites and SQLMetric surfaces the 
cross-task max.
+    def maxIfPresent(name: String, present: Boolean, value: => Long): Unit = {
+      if (present) {
+        val metric = target(name)
+        val reportedValue = value
+        if (metric.isZero || reportedValue > metric.value) {
+          metric.set(reportedValue)
+        }
+      }
+    }
+
+    addIfPresent("bytesIn", reported.hasBytesIn, reported.getBytesIn)
+    addIfPresent("bytesOut", reported.hasBytesOut, reported.getBytesOut)
+    addIfPresent("rowsIn", reported.hasRowsIn, reported.getRowsIn)
+    addIfPresent("rowsOut", reported.hasRowsOut, reported.getRowsOut)
+    addIfPresent("batchesIn", reported.hasBatchesIn, reported.getBatchesIn)
+    addIfPresent("batchesOut", reported.hasBatchesOut, reported.getBatchesOut)
+    addIfPresent("initWallNanos", reported.hasInitWallNanos, 
reported.getInitWallNanos)
+    addIfPresent(
+      "processingWallNanos", reported.hasProcessingWallNanos, 
reported.getProcessingWallNanos)
+    addIfPresent("receiveWallNanos", reported.hasReceiveWallNanos, 
reported.getReceiveWallNanos)
+    addIfPresent("sendWallNanos", reported.hasSendWallNanos, 
reported.getSendWallNanos)
+    addIfPresent("workWallNanos", reported.hasWorkWallNanos, 
reported.getWorkWallNanos)
+    addIfPresent("workCpuNanos", reported.hasWorkCpuNanos, 
reported.getWorkCpuNanos)
+    addIfPresent("finishWallNanos", reported.hasFinishWallNanos, 
reported.getFinishWallNanos)
+    maxIfPresent("maxWorkWallNanos", reported.hasMaxWorkWallNanos, 
reported.getMaxWorkWallNanos)
+    maxIfPresent("maxWorkCpuNanos", reported.hasMaxWorkCpuNanos, 
reported.getMaxWorkCpuNanos)
+  }
 }
diff --git 
a/sql/core/src/test/scala/org/apache/spark/sql/execution/externalUDF/ExecuteExternalUDFExecSuite.scala
 
b/sql/core/src/test/scala/org/apache/spark/sql/execution/externalUDF/ExecuteExternalUDFExecSuite.scala
index 17ebde5ffbbb..29727eaa8ce0 100644
--- 
a/sql/core/src/test/scala/org/apache/spark/sql/execution/externalUDF/ExecuteExternalUDFExecSuite.scala
+++ 
b/sql/core/src/test/scala/org/apache/spark/sql/execution/externalUDF/ExecuteExternalUDFExecSuite.scala
@@ -46,8 +46,9 @@ import org.apache.spark.sql.internal.SQLConf
 import org.apache.spark.sql.test.SharedSparkSession
 import org.apache.spark.sql.types.{DataType, LongType, StructField, StructType}
 import org.apache.spark.sql.util.ArrowUtils
-import org.apache.spark.udf.worker.{Cancel, DataRequest, DataResponse, Finish, 
FinishResponse, Init,
-  InitResponse, UDFWorkerDataFormat, UDFWorkerSpecification}
+import org.apache.spark.udf.worker.{Cancel, CancelResponse, DataRequest, 
DataResponse,
+  ExecutionMetrics, Finish, FinishResponse, Init, InitResponse, 
UDFWorkerDataFormat,
+  UDFWorkerSpecification}
 import org.apache.spark.udf.worker.core.{Termination, WorkerHandle, 
WorkerLogger,
   WorkerSecurityScope, WorkerSession}
 import org.apache.spark.util.{LongAccumulator, Utils}
@@ -86,7 +87,9 @@ object ExecuteExternalUDFExecSuite {
       expectedTimeZone: String,
       expectedLargeVarTypes: Boolean,
       requestCount: LongAccumulator,
-      closeCount: LongAccumulator)
+      closeCount: LongAccumulator,
+      terminalMetrics: Option[ExecutionMetrics],
+      cancelOnClose: Boolean)
     extends WorkerSession(new TestWorkerHandle, WorkerLogger.NoOp) {
 
     override protected def doInit(message: Init): InitResponse = {
@@ -144,7 +147,18 @@ object ExecuteExternalUDFExecSuite {
 
     override protected def doClose(cancel: () => Cancel): Termination = {
       closeCount.add(1L)
-      completeTerminal(Termination.Finished(FinishResponse.getDefaultInstance))
+      val termination = if (cancelOnClose) {
+        val response = terminalMetrics.fold(CancelResponse.getDefaultInstance) 
{ metrics =>
+          CancelResponse.newBuilder().setExecutionMetrics(metrics).build()
+        }
+        Termination.Cancelled(response)
+      } else {
+        val response = terminalMetrics.fold(FinishResponse.getDefaultInstance) 
{ metrics =>
+          FinishResponse.newBuilder().setExecutionMetrics(metrics).build()
+        }
+        Termination.Finished(response)
+      }
+      completeTerminal(termination)
       settledTermination
     }
 
@@ -262,24 +276,24 @@ object ExecuteExternalUDFExecSuite {
       expectedTimeZone: String,
       expectedLargeVarTypes: Boolean,
       requestCount: LongAccumulator,
-      closeCount: LongAccumulator)
+      closeCount: LongAccumulator,
+      terminalMetrics: Option[ExecutionMetrics],
+      cancelOnClose: Boolean)
     extends ExecuteExternalUDFExec(udf, resultAttr, child) {
 
-    override protected def withUDFWorkerSession(
-        taskContext: TaskContext,
-        securityScope: Option[WorkerSecurityScope])(
-        f: WorkerSession => Iterator[InternalRow]): Iterator[InternalRow] = {
+    override protected def createUDFWorkerSession(
+        securityScope: Option[WorkerSecurityScope]): WorkerSession = {
       require(securityScope.isEmpty, "scalar external UDF execution must not 
request a sandbox")
-      val session = new TestWorkerSession(
+      new TestWorkerSession(
         behavior,
         expectedInputSchema,
         expectedOutputSchema,
         expectedTimeZone,
         expectedLargeVarTypes,
         requestCount,
-        closeCount)
-      taskContext.addTaskCompletionListener[Unit](_ => session.close())
-      f(session)
+        closeCount,
+        terminalMetrics,
+        cancelOnClose)
     }
   }
 }
@@ -289,14 +303,18 @@ class ExecuteExternalUDFExecSuite extends QueryTest with 
SharedSparkSession {
 
   private def testExecution(
       behavior: ResponseBehavior,
-      rowCount: Long): TestExecution = {
+      rowCount: Long,
+      terminalMetrics: Option[ExecutionMetrics] = None,
+      cancelOnClose: Boolean = false): TestExecution = {
     val child = spark.range(0L, rowCount, 1L, 1).queryExecution.executedPlan
     testExecution(
       behavior,
       child,
       Seq(child.output.head),
       LongType,
-      udfNullable = false)
+      udfNullable = false,
+      terminalMetrics = terminalMetrics,
+      cancelOnClose = cancelOnClose)
   }
 
   private def testExecution(
@@ -305,6 +323,24 @@ class ExecuteExternalUDFExecSuite extends QueryTest with 
SharedSparkSession {
       udfChildren: Seq[Expression],
       udfDataType: DataType,
       udfNullable: Boolean): TestExecution = {
+    testExecution(
+      behavior,
+      child,
+      udfChildren,
+      udfDataType,
+      udfNullable,
+      terminalMetrics = None,
+      cancelOnClose = false)
+  }
+
+  private def testExecution(
+      behavior: ResponseBehavior,
+      child: SparkPlan,
+      udfChildren: Seq[Expression],
+      udfDataType: DataType,
+      udfNullable: Boolean,
+      terminalMetrics: Option[ExecutionMetrics],
+      cancelOnClose: Boolean): TestExecution = {
     val udf = ExternalUserDefinedFunction(
       name = Some("identity"),
       workerSpec = UDFWorkerSpecification.getDefaultInstance,
@@ -337,7 +373,9 @@ class ExecuteExternalUDFExecSuite extends QueryTest with 
SharedSparkSession {
         conf.sessionLocalTimeZone,
         conf.arrowUseLargeVarTypes,
         requestCount,
-        closeCount),
+        closeCount,
+        terminalMetrics,
+        cancelOnClose),
       requestCount,
       closeCount)
   }
@@ -396,6 +434,97 @@ class ExecuteExternalUDFExecSuite extends QueryTest with 
SharedSparkSession {
       parameters = Map.empty)
   }
 
+  gridTest("external UDF records typed terminal metrics from")(
+      Seq("finish", "cancel")) { terminal =>
+    val reported = ExecutionMetrics.newBuilder()
+      .setBytesIn(1)
+      .setBytesOut(2)
+      .setRowsIn(3)
+      .setRowsOut(4)
+      .setBatchesIn(5)
+      .setBatchesOut(6)
+      .setInitWallNanos(7)
+      .setProcessingWallNanos(8)
+      .setReceiveWallNanos(9)
+      .setSendWallNanos(10)
+      .setWorkWallNanos(11)
+      .setWorkCpuNanos(12)
+      .setFinishWallNanos(13)
+      .setMaxWorkWallNanos(14)
+      .setMaxWorkCpuNanos(15)
+      .build()
+    val execution = testExecution(
+      EchoResponses,
+      rowCount = 5L,
+      terminalMetrics = Some(reported),
+      cancelOnClose = terminal == "cancel")
+    val plan = execution.plan.asInstanceOf[TestExecuteExternalUDFExec]
+
+    terminal match {
+      case "finish" => plan.executeCollect()
+      case "cancel" => plan.executeTake(1)
+      case other => fail(s"unexpected terminal: $other")
+    }
+
+    Seq(
+      "bytesIn", "bytesOut", "rowsIn", "rowsOut", "batchesIn", "batchesOut",
+      "initWallNanos", "processingWallNanos", "receiveWallNanos", 
"sendWallNanos",
+      "workWallNanos", "workCpuNanos", "finishWallNanos", "maxWorkWallNanos",
+      "maxWorkCpuNanos"
+    ).zipWithIndex.foreach { case (name, index) =>
+      assert(plan.metrics(name).value === index + 1L)
+    }
+  }
+
+  test("external UDF keeps the max, not the sum, of peak metrics across 
sessions in a task") {
+    val metrics = ExternalUDFMetrics.create(spark.sparkContext)
+    def snapshot(workWallNanos: Long, maxWall: Long, maxCpu: Long): 
ExecutionMetrics =
+      ExecutionMetrics.newBuilder()
+        .setWorkWallNanos(workWallNanos)
+        .setMaxWorkWallNanos(maxWall)
+        .setMaxWorkCpuNanos(maxCpu)
+        .build()
+
+    ExternalUDFMetrics.update(metrics, snapshot(workWallNanos = 10, maxWall = 
10, maxCpu = 4))
+    ExternalUDFMetrics.update(metrics, snapshot(workWallNanos = 20, maxWall = 
5, maxCpu = 8))
+
+    assert(metrics("workWallNanos").value === 30L)
+    assert(metrics("maxWorkWallNanos").value === 10L)
+    assert(metrics("maxWorkCpuNanos").value === 8L)
+  }
+
+  test("external UDF preserves missing and measured zero terminal metrics") {
+    // bytesIn exercises a measured zero on the additive path; maxWorkCpuNanos 
exercises one on the
+    // keep-max path, where only the metric.isZero branch records it (0 never 
exceeds the unset
+    // value, which reads 0). maxWorkWallNanos stays unset to cover keep-max 
absence.
+    val reported = ExecutionMetrics.newBuilder()
+      .setBytesIn(0)
+      .setMaxWorkCpuNanos(0)
+      .build()
+    val execution = testExecution(
+      EchoResponses,
+      rowCount = 1L,
+      terminalMetrics = Some(reported))
+    val plan = execution.plan.asInstanceOf[TestExecuteExternalUDFExec]
+
+    plan.executeCollect()
+
+    Seq("bytesIn", "maxWorkCpuNanos").foreach { name =>
+      assert(plan.metrics(name).value === 0L)
+      assert(!plan.metrics(name).isZero, s"$name should report measured zero")
+    }
+    Seq(
+      "bytesOut", "initWallNanos", "processingWallNanos", "receiveWallNanos", 
"sendWallNanos",
+      "workWallNanos", "workCpuNanos", "finishWallNanos", "maxWorkWallNanos"
+    ).foreach { name =>
+      assert(plan.metrics(name).isZero, s"$name should be unreported")
+    }
+    // toInfoUpdate is the raw value the live SQL UI sums; value hides a 
negative initial value.
+    Seq("rowsIn", "rowsOut", "batchesIn", "batchesOut").foreach { name =>
+      assert(plan.metrics(name).toInfoUpdate.update === Some(0L))
+    }
+  }
+
   test("scalar external UDF exchanges multiple Arrow batches through a worker 
session") {
     withSQLConf(
         SQLConf.SESSION_LOCAL_TIMEZONE.key -> "UTC",
diff --git 
a/udf/worker/core/src/main/scala/org/apache/spark/udf/worker/core/WorkerSession.scala
 
b/udf/worker/core/src/main/scala/org/apache/spark/udf/worker/core/WorkerSession.scala
index 0afde972bc29..7cef64c1da33 100644
--- 
a/udf/worker/core/src/main/scala/org/apache/spark/udf/worker/core/WorkerSession.scala
+++ 
b/udf/worker/core/src/main/scala/org/apache/spark/udf/worker/core/WorkerSession.scala
@@ -50,7 +50,7 @@ import org.apache.spark.udf.worker.{Cancel, DataRequest, 
DataResponse, Finish, I
  *
  * '''Lifecycle:'''
  *  - [[init]] must be called exactly once before [[process]]; it returns the
- *    worker's [[InitResponse]] (init-callback result + metrics).
+ *    worker's [[InitResponse]] (init-callback result).
  *  - [[process]] must be called at most once per session. It returns the
  *    result iterator; consuming that iterator drives the data exchange and
  *    surfaces any UDF / data-phase error (as an exception from `next()` /
@@ -148,7 +148,7 @@ abstract class WorkerSession(
   /**
    * Initializes the UDF execution. Must be called exactly once before
    * [[process]]. Returns the worker's [[InitResponse]] (carrying the
-   * init-callback result and metrics) on success; throws if the worker
+   * init-callback result) on success; throws if the worker
    * reports an init error or the stream fails before init completes.
    *
    * @param message the [[Init]] message carrying the UDF body, data
diff --git 
a/udf/worker/proto/src/main/protobuf/org/apache/spark/udf/worker/udf_message.proto
 
b/udf/worker/proto/src/main/protobuf/org/apache/spark/udf/worker/udf_message.proto
index e1be17b15d9f..d1bf2b5dda85 100644
--- 
a/udf/worker/proto/src/main/protobuf/org/apache/spark/udf/worker/udf_message.proto
+++ 
b/udf/worker/proto/src/main/protobuf/org/apache/spark/udf/worker/udf_message.proto
@@ -395,10 +395,13 @@ message Finish {}
 // Worker -> Engine completion message. Carries per-execution summary metrics.
 //
 // Metrics design:
-//   - [[FinishResponse.metrics]] / [[CancelResponse.metrics]]: per-execution
-//     metrics accumulated up to the point of stream termination (e.g. rows
-//     processed, time per phase). Emitted once per [[Execute]] stream,
+//   - [[FinishResponse.execution_metrics]] /
+//     [[CancelResponse.execution_metrics]]: per-execution metrics with
+//     defined semantics (see [[ExecutionMetrics]]), accumulated up to the
+//     point of stream termination. Emitted once per [[Execute]] stream,
 //     regardless of whether the stream ended cleanly or was cancelled.
+//   - [[FinishResponse.metrics]] / [[CancelResponse.metrics]]: free-form,
+//     worker-defined per-execution metrics, sent alongside them.
 //   - [[HeartbeatResponse.metrics]]: worker-global metrics aggregated across
 //     all sessions (e.g. total rows processed, memory usage). Emitted
 //     periodically via the [[Manage]] RPC.
@@ -426,6 +429,11 @@ message FinishResponse {
     // data processing, the terminator is [[CancelResponse]], not
     // [[FinishResponse]]. The engine SHOULD surface this as an exception.
     optional ExecutionError error = 3;
+
+    // (Optional) Final metrics accumulated over this Execute stream, in
+    // the typed fields the engine reads. Measurements that have no field
+    // there belong in [[metrics]].
+    optional ExecutionMetrics execution_metrics = 4;
 }
 
 // Engine -> Worker explicit cancel. Distinct from a gRPC stream error
@@ -480,6 +488,88 @@ message CancelResponse {
     // alongside any prior [[ExecutionError]] (e.g. as a suppressed
     // exception).
     optional ExecutionError error = 2;
+
+    // (Optional) Partial metrics accumulated over this Execute stream up
+    // to cancellation. See [[FinishResponse.execution_metrics]].
+    optional ExecutionMetrics execution_metrics = 3;
+}
+
+// Per-execution metrics accumulated by one Execute stream, as measured by the
+// worker. Each field is cumulative for the stream, except
+// [[max_work_wall_nanos]] and [[max_work_cpu_nanos]], which are per-stream
+// maxima. Absence means unavailable;
+// zero means measured zero. "In" counts data the worker received from the
+// engine and "out" counts data it returned. Byte counts are data payload
+// bytes and exclude gRPC, HTTP/2, and TLS framing. Timing values are
+// nanoseconds; the wait times [[receive_wall_nanos]] and [[send_wall_nanos]]
+// are not transfer times and MUST NOT be used as rate denominators. Spark
+// reads each field as a signed 64-bit value and ignores values above
+// 2^63 - 1.
+//
+// [[init_wall_nanos]], [[processing_wall_nanos]], and [[finish_wall_nanos]]
+// are consecutive windows of the stream. [[receive_wall_nanos]],
+// [[send_wall_nanos]], [[work_wall_nanos]], and [[work_cpu_nanos]] are
+// totals, summed across the worker's threads. They can overlap and do not
+// add up to any window; [[work_wall_nanos]] can exceed
+// [[processing_wall_nanos]].
+message ExecutionMetrics {
+    // Bytes of [[DataRequest.data]] payload received.
+    optional uint64 bytes_in = 1;
+
+    // Bytes of [[DataResponse.data]] payload returned.
+    optional uint64 bytes_out = 2;
+
+    // Rows in the received data payloads. Absent unless the rows of every
+    // received payload could be counted.
+    optional uint64 rows_in = 3;
+
+    // Rows in the returned data payloads. Absent unless the rows of every
+    // returned payload could be counted.
+    optional uint64 rows_out = 4;
+
+    // Number of [[DataRequest]] messages received.
+    optional uint64 batches_in = 5;
+
+    // Number of [[DataResponse]] messages returned.
+    optional uint64 batches_out = 6;
+
+    // Wall time spent handling [[Init]] before sending [[InitResponse]].
+    optional uint64 init_wall_nanos = 7;
+
+    // Wall time from sending [[InitResponse]] until data processing ends,
+    // when the worker has received [[Finish]] and sent all of its output.
+    // Cancellation or an execution error also ends it.
+    optional uint64 processing_wall_nanos = 8;
+
+    // Wall time spent waiting for the next request from the engine.
+    optional uint64 receive_wall_nanos = 9;
+
+    // Wall time spent blocked sending responses to the engine.
+    optional uint64 send_wall_nanos = 10;
+
+    // Wall time spent executing received data: decoding input, running the
+    // UDF, and encoding output. Excludes the wait times.
+    optional uint64 work_wall_nanos = 11;
+
+    // CPU time consumed executing received data.
+    optional uint64 work_cpu_nanos = 12;
+
+    // Wall time from the end of data processing until sending
+    // [[FinishResponse]] or [[CancelResponse]], such as running the finish
+    // callback. Absent if cancellation or an error ended data processing.
+    optional uint64 finish_wall_nanos = 13;
+
+    // Wall time of the single longest data unit the worker executed: the
+    // maximum over the stream of the per-unit work time that
+    // [[work_wall_nanos]] totals. Lets the engine surface the slowest unit,
+    // which the summed total cannot. Not cumulative. Absent means unavailable.
+    optional uint64 max_work_wall_nanos = 14;
+
+    // CPU time of the single most CPU-intensive data unit the worker executed:
+    // the maximum over the stream of the per-unit CPU time that
+    // [[work_cpu_nanos]] totals. The CPU counterpart of 
[[max_work_wall_nanos]].
+    // Not cumulative. Absent means unavailable.
+    optional uint64 max_work_cpu_nanos = 15;
 }
 
 // Worker -> Engine application-level error raised during the


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

Reply via email to