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

dongjoon-hyun pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/spark-connect-swift.git


The following commit(s) were added to refs/heads/main by this push:
     new 65e60b2  [SPARK-58608] Support `Observation` to retrieve observed 
metrics
65e60b2 is described below

commit 65e60b228e019f79e315023ef49715d88c9ae8e8
Author: Dongjoon Hyun <[email protected]>
AuthorDate: Wed Aug 5 20:14:17 2026 -0700

    [SPARK-58608] Support `Observation` to retrieve observed metrics
    
    ### What changes were proposed in this pull request?
    
    This PR aims to support `Observation` to retrieve the observed metrics of 
`DataFrame.observe`.
    
    ```swift
    let observation = Observation("my_metrics")
    let observedDf = try await df.observe(observation, count(col("*")), 
max(col("id")))
    try await observedDf.count()
    let metrics = try await observation.get  // ["count(1)": Int64(10), 
"max(id)": ...]
    ```
    
    - Add a new `Observation` actor whose `get` property returns the observed 
metrics keyed by the metric column names, or throws 
`SparkConnectError.invalidState` if no action has been performed yet.
    - Add a `DataFrame.observe(_ observation: Observation, _ expr: Column, _ 
exprs: Column...)` overload which registers the observation to 
`SparkConnectClient`.
    - Collect `ExecutePlanResponse.observed_metrics` inside 
`executePlanWithReattach` and deliver the values to the registered observations 
by matching `ObservedMetrics.name`, like the PySpark Connect client. Since a 
reattached stream resumes after `last_response_id`, no response is delivered 
twice.
    - Add an `ExpressionLiteral.toSwiftValue` helper converting metric value 
literals back to Swift values.
    
    ### Why are the changes needed?
    
    SPARK-58425 added `DataFrame.observe` which builds the `CollectMetrics` 
relation, but the observed metric values returned by the server via 
`ExecutePlanResponse.observed_metrics` were discarded, so users had no way to 
read them. This provides the `Observation` API with the same usage as Scala and 
PySpark.
    
    ### Does this PR introduce _any_ user-facing change?
    
    No. This is a new feature addition.
    
    ### How was this patch tested?
    
    Pass the CIs with a newly added test case.
    
    ### Was this patch authored or co-authored using generative AI tooling?
    
    Generated-by: Claude Fable 5
    
    Closes #484 from dongjoon-hyun/SPARK-58608.
    
    Authored-by: Dongjoon Hyun <[email protected]>
    Signed-off-by: Dongjoon Hyun <[email protected]>
---
 .../SparkConnect/DataFrame+Transformations.swift   | 25 ++++++++
 Sources/SparkConnect/Extension.swift               | 23 +++++++
 Sources/SparkConnect/Observation.swift             | 70 ++++++++++++++++++++++
 .../SparkConnectClient+ReattachableExecute.swift   |  3 +
 Sources/SparkConnect/SparkConnectClient.swift      | 20 +++++++
 Tests/SparkConnectTests/DataFrameTests.swift       | 17 ++++++
 6 files changed, 158 insertions(+)

diff --git a/Sources/SparkConnect/DataFrame+Transformations.swift 
b/Sources/SparkConnect/DataFrame+Transformations.swift
index 0134181..ccb83c9 100644
--- a/Sources/SparkConnect/DataFrame+Transformations.swift
+++ b/Sources/SparkConnect/DataFrame+Transformations.swift
@@ -102,6 +102,31 @@ extension DataFrame {
       spark: self.spark, plan: 
SparkConnectClient.getCollectMetrics(self.plan.root, name, metrics))
   }
 
+  /// Defines (named) metrics to observe on the ``DataFrame`` through an 
``Observation`` instance.
+  /// The metric values are delivered to the ``Observation`` when an action is 
performed on the
+  /// returned ``DataFrame``.
+  ///
+  /// ```swift
+  /// let observation = Observation("my_metrics")
+  /// let observedDf = try await df.observe(observation, count(col("*")), 
max(col("id")))
+  /// try await observedDf.count()
+  /// let metrics = try await observation.get
+  /// ```
+  /// - Parameters:
+  ///   - observation: An ``Observation`` instance to receive the observed 
metrics.
+  ///   - expr: A metric ``Column`` expression to compute.
+  ///   - exprs: Additional metric ``Column`` expressions.
+  /// - Returns: An observed ``DataFrame``.
+  public func observe(_ observation: Observation, _ expr: Column, _ exprs: 
Column...) async
+    -> DataFrame
+  {
+    await spark.client.addObservation(observation)
+    let metrics = ([expr] + exprs).map { $0.expr }
+    return DataFrame(
+      spark: self.spark,
+      plan: SparkConnectClient.getCollectMetrics(self.plan.root, 
observation.name, metrics))
+  }
+
   /// Returns a new Dataset with a column dropped. This is a no-op if schema 
doesn't contain column name.
   /// - Parameter cols: Column names
   /// - Returns: A ``DataFrame`` with subset of columns.
diff --git a/Sources/SparkConnect/Extension.swift 
b/Sources/SparkConnect/Extension.swift
index f7e038f..7983ba7 100644
--- a/Sources/SparkConnect/Extension.swift
+++ b/Sources/SparkConnect/Extension.swift
@@ -210,6 +210,29 @@ extension ExpressionLiteral {
       }
     }
   }
+
+  /// A Swift value converted from this `ExpressionLiteral`, or nil for null 
and unsupported types.
+  var toSwiftValue: Sendable? {
+    switch self.literalType {
+    case .boolean(let value): value
+    case .byte(let value): Int8(value)
+    case .short(let value): Int16(value)
+    case .integer(let value): value
+    case .long(let value): value
+    case .float(let value): value
+    case .double(let value): value
+    case .decimal(let value): SwiftDecimal(string: value.value)
+    case .string(let value): value
+    case .binary(let value): value
+    // Date in units of days since the UNIX epoch.
+    case .date(let value): Date(timeIntervalSince1970: TimeInterval(value) * 
86_400)
+    // Timestamp in units of microseconds since the UNIX epoch.
+    case .timestamp(let value): Date(timeIntervalSince1970: 
TimeInterval(value) / 1_000_000)
+    case .timestampNtz(let value): Date(timeIntervalSince1970: 
TimeInterval(value) / 1_000_000)
+    case .time(let value): LocalTime(nanoOfDay: value.nano)
+    default: nil
+    }
+  }
 }
 
 extension [String: String] {
diff --git a/Sources/SparkConnect/Observation.swift 
b/Sources/SparkConnect/Observation.swift
new file mode 100644
index 0000000..b808227
--- /dev/null
+++ b/Sources/SparkConnect/Observation.swift
@@ -0,0 +1,70 @@
+//
+// 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.
+//
+
+#if canImport(FoundationEssentials)
+import FoundationEssentials
+#else
+import Foundation
+#endif
+
+/// A helper class to observe metrics of a ``DataFrame`` while an action is 
performed on it.
+///
+/// ```swift
+/// let observation = Observation("my_metrics")
+/// let observedDf = try await df.observe(observation, count(col("*")), 
max(col("id")))
+/// try await observedDf.count()
+/// let metrics = try await observation.get
+/// ```
+public actor Observation {
+  /// The name of the observation.
+  public nonisolated let name: String
+
+  private var values: [String: Sendable]? = nil
+
+  /// Create an `Observation` instance with a random name.
+  public init() {
+    self.name = UUID().uuidString
+  }
+
+  /// Create an `Observation` instance with the given name.
+  /// - Parameter name: The name of the observation.
+  public init(_ name: String) {
+    self.name = name
+  }
+
+  /// The observed metrics keyed by the metric column names. Metrics whose 
observed values are
+  /// null are not included.
+  /// - Throws: `SparkConnectError.invalidState` if no action has been 
performed on the observed
+  /// ``DataFrame`` yet.
+  public var get: [String: Sendable] {
+    get throws {
+      guard let values else {
+        throw SparkConnectError.invalidState(
+          SparkConnectError.Details(
+            message: "No metrics are observed yet. Perform an action on the 
observed DataFrame."))
+      }
+      return values
+    }
+  }
+
+  /// Update the observed metric values delivered by an `ExecutePlanResponse`.
+  func setValues(_ values: [String: Sendable]) {
+    self.values = values
+  }
+}
diff --git a/Sources/SparkConnect/SparkConnectClient+ReattachableExecute.swift 
b/Sources/SparkConnect/SparkConnectClient+ReattachableExecute.swift
index 2fc05a6..c1b3817 100644
--- a/Sources/SparkConnect/SparkConnectClient+ReattachableExecute.swift
+++ b/Sources/SparkConnect/SparkConnectClient+ReattachableExecute.swift
@@ -121,6 +121,9 @@ extension SparkConnectClient {
     ) async throws {
       for try await m in response.messages {
         try state.record(m)
+        if !m.observedMetrics.isEmpty {
+          await self.updateObservations(m.observedMetrics)
+        }
         try await processResponse(m)
       }
     }
diff --git a/Sources/SparkConnect/SparkConnectClient.swift 
b/Sources/SparkConnect/SparkConnectClient.swift
index caf8ca9..bff6214 100644
--- a/Sources/SparkConnect/SparkConnectClient.swift
+++ b/Sources/SparkConnect/SparkConnectClient.swift
@@ -857,6 +857,26 @@ public actor SparkConnectClient {
     self.result.append(response)
   }
 
+  private var observations: [String: Observation] = [:]
+
+  /// Register an ``Observation`` to receive the observed metrics of its name.
+  func addObservation(_ observation: Observation) {
+    observations[observation.name] = observation
+  }
+
+  /// Update the registered ``Observation``s with the observed metrics of an
+  /// `ExecutePlanResponse`.
+  func updateObservations(_ metrics: [ExecutePlanResponse.ObservedMetrics]) 
async {
+    for metric in metrics {
+      guard let observation = observations[metric.name] else { continue }
+      var values: [String: Sendable] = [:]
+      for (key, value) in zip(metric.keys, metric.values) {
+        values[key] = value.toSwiftValue
+      }
+      await observation.setValues(values)
+    }
+  }
+
   @discardableResult
   func execute(_ sessionID: String, _ command: Command) async throws -> 
[ExecutePlanResponse] {
     self.result.removeAll()
diff --git a/Tests/SparkConnectTests/DataFrameTests.swift 
b/Tests/SparkConnectTests/DataFrameTests.swift
index 3ee9e67..261b58b 100644
--- a/Tests/SparkConnectTests/DataFrameTests.swift
+++ b/Tests/SparkConnectTests/DataFrameTests.swift
@@ -367,6 +367,23 @@ struct DataFrameTests {
     await spark.stop()
   }
 
+  @Test
+  func observation() async throws {
+    let spark = try await SparkSession.builder.getOrCreate()
+    let observation = Observation("my_metrics")
+    let df = try await spark.range(10)
+      .observe(observation, SparkConnect.count(col("id")), sum(col("id")), 
max(col("id")))
+    await #expect(throws: SparkConnectError.self) {
+      try await observation.get
+    }
+    #expect(try await df.count() == 10)
+    let metrics = try await observation.get
+    #expect(metrics["count(id)"] as? Int64 == 10)
+    #expect(metrics["sum(id)"] as? Int64 == 45)
+    #expect(metrics["max(id)"] as? Int64 == 9)
+    await spark.stop()
+  }
+
   @Test
   func withColumnRenamed() async throws {
     let spark = try await SparkSession.builder.getOrCreate()


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

Reply via email to