Skip to content
Closed
Show file tree
Hide file tree
Changes from 18 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -3337,8 +3337,72 @@ class Dataset[T] private[sql] (
}
}

/**
* Define (named) metrics to observe on the Dataset. This method returns an 'observed' Dataset
* that returns the same result as the input, with the following guarantees:
* <ul>
* <li>It will compute the defined aggregates (metrics) on all the data that is flowing through
* the Dataset at that point.</li>
* <li>It will report the value of the defined aggregate columns as soon as we reach a
* completion point. A completion point is currently defined as the end of a query.</li>
* </ul>
* Please note that continuous execution is currently not supported.
*
* The metrics columns must either contain a literal (e.g. lit(42)), or should contain one or
* more aggregate functions (e.g. sum(a) or sum(a + b) + avg(c) - lit(1)). Expressions that
* contain references to the input Dataset's columns must always be wrapped in an aggregate
* function.
*
* A user can retrieve the metrics by calling
* `org.apache.spark.sql.Dataset.collectObservations()`.
*
* {{{
* // Observe row count (rows) and highest id (maxid) in the Dataset while writing it
* val observed_ds = ds.observe("my_metrics", count(lit(1)).as("rows"), max($"id").as("maxid"))
* observed_ds.write.parquet("ds.parquet")
* val metrics = observed_ds.collectObservations()
* }}}
*
* @group typedrel
* @since 4.0.0
*/
@scala.annotation.varargs
def observe(name: String, expr: Column, exprs: Column*): Dataset[T] = {
Comment thread
xupefei marked this conversation as resolved.
throw new UnsupportedOperationException("observe is not implemented.")
sparkSession.newDataset(agnosticEncoder) { builder =>
builder.getCollectMetricsBuilder
.setInput(plan.getRoot)
.setName(name)
.addAllMetrics((expr +: exprs).map(_.expr).asJava)
}
}

/**
* Observe (named) metrics through an `org.apache.spark.sql.Observation` instance.
* This is equivalent to calling `observe(String, Column, Column*)` but does not require to
* collect all results before returning the metrics - the metrics are filled during iterating
* the results, as soon as they are available.
* This method does not support streaming datasets.
*
* A user can retrieve the metrics by accessing `org.apache.spark.sql.Observation.get`.
*
* {{{
* // Observe row count (rows) and highest id (maxid) in the Dataset while writing it
* val observation = Observation("my_metrics")
* val observed_ds = ds.observe(observation, count(lit(1)).as("rows"), max($"id").as("maxid"))
* observed_ds.write.parquet("ds.parquet")
* val metrics = observation.get
* }}}
*
* @throws IllegalArgumentException If this is a streaming Dataset (this.isStreaming == true)
*
* @group typedrel
* @since 4.0.0
*/
@scala.annotation.varargs
def observe(observation: Observation, expr: Column, exprs: Column*): Dataset[T] = {
Comment thread
xupefei marked this conversation as resolved.
val df = observe(observation.name, expr, exprs: _*)
observation.register(sparkSession, df.getPlanId.get)
df
}

def checkpoint(): Dataset[T] = {
Expand Down Expand Up @@ -3399,6 +3463,9 @@ class Dataset[T] private[sql] (

def collectResult(): SparkResult[T] = sparkSession.execute(plan, agnosticEncoder)

def collectObservations(): Map[String, Map[String, Any]] =
collectResult().getObservedMetrics
Comment thread
xupefei marked this conversation as resolved.
Outdated

private[sql] def withResult[E](f: SparkResult[T] => E): E = {
val result = collectResult()
try f(result)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package org.apache.spark.sql

import java.util.UUID

class Observation(name: String) extends ObservationBase(name) {
Comment thread
xupefei marked this conversation as resolved.

/**
* Create an Observation instance without providing a name. This generates a random name.
*/
def this() = this(UUID.randomUUID().toString)

@volatile private var planId: Option[(SparkSession, Long)] = None

private[sql] def register(sparkSession: SparkSession, planId: Long): Unit = {
// makes this class thread-safe:
// only the first thread entering this block can set sparkSession
// all other threads will see the exception, as it is only allowed to do this once
synchronized {
Comment thread
xupefei marked this conversation as resolved.
Outdated
if (this.planId.isDefined) {
throw new IllegalArgumentException("An Observation can be used with a Dataset only once")
}
this.planId = Some((sparkSession, planId))
}

sparkSession.observationRegistry.put(planId, this)
}

private def unregister(): Unit = {
Comment thread
xupefei marked this conversation as resolved.
Outdated
this.planId.map { case (sparkSession, planId) =>
sparkSession.observationRegistry.remove(planId)
}
}

override private[spark] def setMetricsAndNotify(metrics: Option[Map[String, Any]]): Boolean = {
this.unregister()
super.setMetricsAndNotify(metrics)
}
}

/**
* (Scala-specific) Create instances of Observation via Scala `apply`.
* @since 3.3.0
Comment thread
xupefei marked this conversation as resolved.
Outdated
*/
object Observation {

/**
* Observation constructor for creating an anonymous observation.
*/
def apply(): Observation = new Observation()

/**
* Observation constructor for creating a named observation.
*/
def apply(name: String): Observation = new Observation(name)

}
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import java.net.URI
import java.util.concurrent.TimeUnit._
import java.util.concurrent.atomic.{AtomicLong, AtomicReference}

import scala.collection.mutable
import scala.jdk.CollectionConverters._
import scala.reflect.runtime.universe.TypeTag

Expand All @@ -36,7 +37,7 @@ import org.apache.spark.sql.catalog.Catalog
import org.apache.spark.sql.catalyst.{JavaTypeInference, ScalaReflection}
import org.apache.spark.sql.catalyst.encoders.{AgnosticEncoder, RowEncoder}
import org.apache.spark.sql.catalyst.encoders.AgnosticEncoders.{BoxedLongEncoder, UnboundRowEncoder}
import org.apache.spark.sql.connect.client.{ClassFinder, SparkConnectClient, SparkResult}
import org.apache.spark.sql.connect.client.{ClassFinder, CloseableIterator, SparkConnectClient, SparkResult}
import org.apache.spark.sql.connect.client.SparkConnectClient.Configuration
import org.apache.spark.sql.connect.client.arrow.ArrowSerializer
import org.apache.spark.sql.functions.lit
Expand Down Expand Up @@ -80,6 +81,8 @@ class SparkSession private[sql] (
client.analyze(proto.AnalyzePlanRequest.AnalyzeCase.SPARK_VERSION).getSparkVersion.getVersion
}

private[sql] val observationRegistry = mutable.Map.empty[Long, Observation]

/**
* Runtime configuration interface for Spark.
*
Expand Down Expand Up @@ -552,8 +555,7 @@ class SparkSession private[sql] (

private[sql] def execute[T](plan: proto.Plan, encoder: AgnosticEncoder[T]): SparkResult[T] = {
val value = client.execute(plan)
val result = new SparkResult(value, allocator, encoder, timeZoneId)
result
new SparkResult(value, allocator, encoder, timeZoneId, Some(this.observationRegistry.toMap))
}

private[sql] def execute(f: proto.Relation.Builder => Unit): Unit = {
Expand All @@ -572,6 +574,9 @@ class SparkSession private[sql] (
client.execute(plan).filter(!_.hasExecutionProgress).toSeq
}

private[sql] def execute(plan: proto.Plan): CloseableIterator[ExecutePlanResponse] =
client.execute(plan)

private[sql] def registerUdf(udf: proto.CommonInlineUserDefinedFunction): Unit = {
val command = proto.Command.newBuilder().setRegisterFunction(udf).build()
execute(command)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ import java.time.DateTimeException
import java.util.Properties

import scala.collection.mutable
import scala.concurrent.{ExecutionContext, Future}
import scala.concurrent.duration.DurationInt
import scala.jdk.CollectionConverters._

import org.apache.commons.io.FileUtils
Expand All @@ -41,6 +43,7 @@ import org.apache.spark.sql.internal.SqlApiConf
import org.apache.spark.sql.test.{IntegrationTestUtils, RemoteSparkSession, SQLHelper}
import org.apache.spark.sql.test.SparkConnectServerUtils.port
import org.apache.spark.sql.types._
import org.apache.spark.util.SparkThreadUtils

class ClientE2ETestSuite extends RemoteSparkSession with SQLHelper with PrivateMethodTester {

Expand Down Expand Up @@ -1511,6 +1514,38 @@ class ClientE2ETestSuite extends RemoteSparkSession with SQLHelper with PrivateM
(0 until 5).foreach(i => assert(row.get(i * 2) === row.get(i * 2 + 1)))
}
}

test("Observable metrics") {
val df = spark.range(99).withColumn("extra", col("id") - 1)
val ob1 = new Observation("ob1")
val observedDf = df.observe(ob1, min("id"), avg("id"), max("id"))
val observedObservedDf = observedDf.observe("ob2", min("extra"), avg("extra"), max("extra"))

val ob1Metrics = Map("ob1" -> Map("min(id)" -> 0, "avg(id)" -> 49, "max(id)" -> 98))
val ob2Metrics = Map("ob2" -> Map("min(extra)" -> -1, "avg(extra)" -> 48, "max(extra)" -> 97))

assert(df.collectObservations() === Map.empty)
assert(observedDf.collectObservations() === ob1Metrics)
assert(observedObservedDf.collectObservations() === ob1Metrics ++ ob2Metrics)
}

test("Observation.get is blocked until the query is finished") {
val df = spark.range(99).withColumn("extra", col("id") - 1)
val observation = new Observation("ob1")
val observedDf = df.observe(observation, min("id"), avg("id"), max("id"))

// Start a new thread to get the observation
val future = Future(observation.get)(ExecutionContext.global)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For the record. IMO the observation class should have been using a future from the get go.

// make sure the thread is blocked right now
val e = intercept[java.util.concurrent.TimeoutException] {
SparkThreadUtils.awaitResult(future, 2.seconds)
}
assert(e.getMessage.contains("Future timed out"))
observedDf.collect()
// make sure the thread is unblocked after the query is finished
val metrics = SparkThreadUtils.awaitResult(future, 2.seconds)
assert(metrics === Map("min(id)" -> 0, "avg(id)" -> 49, "max(id)" -> 98))
}
}

private[sql] case class ClassData(a: String, b: Int)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -196,9 +196,6 @@ object CheckConnectJvmClientCompatibility {
ProblemFilters.exclude[Problem]("org.apache.spark.sql.Dataset.COL_POS_KEY"),
ProblemFilters.exclude[Problem]("org.apache.spark.sql.Dataset.DATASET_ID_KEY"),
ProblemFilters.exclude[Problem]("org.apache.spark.sql.Dataset.curId"),
ProblemFilters.exclude[Problem]("org.apache.spark.sql.Dataset.observe"),
ProblemFilters.exclude[MissingClassProblem]("org.apache.spark.sql.Observation"),
ProblemFilters.exclude[MissingClassProblem]("org.apache.spark.sql.Observation$"),
ProblemFilters.exclude[MissingClassProblem]("org.apache.spark.sql.ObservationListener"),
ProblemFilters.exclude[MissingClassProblem]("org.apache.spark.sql.ObservationListener$"),
ProblemFilters.exclude[Problem]("org.apache.spark.sql.Dataset.queryExecution"),
Expand Down Expand Up @@ -363,6 +360,8 @@ object CheckConnectJvmClientCompatibility {
), // developer API
ProblemFilters.exclude[DirectMissingMethodProblem](
"org.apache.spark.sql.Dataset.collectResult"),
ProblemFilters.exclude[DirectMissingMethodProblem](
Comment thread
xupefei marked this conversation as resolved.
Outdated
"org.apache.spark.sql.Dataset.collectObservations"),

// RuntimeConfig
ProblemFilters.exclude[MissingTypesProblem](
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -434,6 +434,7 @@ message ExecutePlanResponse {
string name = 1;
repeated Expression.Literal values = 2;
repeated string keys = 3;
int64 plan_id = 4;
}

message ResultComplete {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,18 +27,21 @@ import org.apache.arrow.vector.ipc.message.{ArrowMessage, ArrowRecordBatch}
import org.apache.arrow.vector.types.pojo

import org.apache.spark.connect.proto
import org.apache.spark.connect.proto.ExecutePlanResponse.ObservedMetrics
import org.apache.spark.sql.ObservationBase
import org.apache.spark.sql.catalyst.encoders.{AgnosticEncoder, RowEncoder}
import org.apache.spark.sql.catalyst.encoders.AgnosticEncoders.{ProductEncoder, UnboundRowEncoder}
import org.apache.spark.sql.connect.client.arrow.{AbstractMessageIterator, ArrowDeserializingIterator, ConcatenatingArrowStreamReader, MessageIterator}
import org.apache.spark.sql.connect.common.DataTypeProtoConverter
import org.apache.spark.sql.connect.common.{DataTypeProtoConverter, LiteralValueProtoConverter}
import org.apache.spark.sql.types.{DataType, StructType}
import org.apache.spark.sql.util.ArrowUtils

private[sql] class SparkResult[T](
responses: CloseableIterator[proto.ExecutePlanResponse],
allocator: BufferAllocator,
encoder: AgnosticEncoder[T],
timeZoneId: String)
timeZoneId: String,
observationsOpt: Option[Map[Long, ObservationBase]] = None)
Comment thread
xupefei marked this conversation as resolved.
Outdated
extends AutoCloseable { self =>

case class StageInfo(
Expand Down Expand Up @@ -79,6 +82,7 @@ private[sql] class SparkResult[T](
private[this] var arrowSchema: pojo.Schema = _
private[this] var nextResultIndex: Int = 0
private val resultMap = mutable.Map.empty[Int, (Long, Seq[ArrowMessage])]
private val observedMetrics = mutable.Map.empty[String, Map[String, Any]]
Comment thread
xupefei marked this conversation as resolved.
Outdated
private val cleanable =
SparkResult.cleaner.register(this, new SparkResultCloseable(resultMap, responses))

Expand Down Expand Up @@ -117,6 +121,9 @@ private[sql] class SparkResult[T](
while (!stop && responses.hasNext) {
val response = responses.next()

// Collect metrics for this response
observedMetrics ++= processObservedMetrics(response.getObservedMetricsList)

// Save and validate operationId
if (opId == null) {
opId = response.getOperationId
Expand Down Expand Up @@ -198,6 +205,26 @@ private[sql] class SparkResult[T](
nonEmpty
}

private def processObservedMetrics(
metrics: java.util.List[ObservedMetrics]): Iterable[(String, Map[String, Any])] = {
val processed = mutable.ListBuffer.empty[(String, Map[String, Any])]
metrics.forEach { metric =>
assert(metric.getKeysCount == metric.getValuesCount)
val kv = (0 until metric.getKeysCount).map { i =>
val key = metric.getKeys(i)
val value = LiteralValueProtoConverter.toCatalystValue(metric.getValues(i))
key -> value
}.toMap
processed += metric.getName -> kv
// If the metrics is registered by an Observation object, attach them and unblock any
// blocked thread.
observationsOpt.map { observations =>
observations.get(metric.getPlanId).map(_.setMetricsAndNotify(Some(kv)))
}
}
processed
}

/**
* Returns the number of elements in the result.
*/
Expand Down Expand Up @@ -248,6 +275,15 @@ private[sql] class SparkResult[T](
result
}

/**
* Returns all observed metrics in the result.
*/
def getObservedMetrics: Map[String, Map[String, Any]] = {
// We need to process all responses to get all metrics.
processResponses()
observedMetrics.toMap
}

/**
* Returns an iterator over the contents of the result.
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -216,6 +216,7 @@ private[connect] class ExecuteThreadRunner(executeHolder: ExecuteHolder) extends
.createObservedMetricsResponse(
executeHolder.sessionHolder.sessionId,
executeHolder.sessionHolder.serverSessionId,
executeHolder.request.getPlan.getRoot.getCommon.getPlanId,
observedMetrics ++ accumulatedInPython))
}

Expand Down
Loading