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 1650218  [SPARK-58653] Support typed `StreamingQueryProgress` in 
`(last|recent)Progress`
1650218 is described below

commit 1650218bf4c2dff094f53ef4b618d08e9e2f29f7
Author: Dongjoon Hyun <[email protected]>
AuthorDate: Fri Aug 7 13:28:21 2026 -0700

    [SPARK-58653] Support typed `StreamingQueryProgress` in 
`(last|recent)Progress`
    
    ### What changes were proposed in this pull request?
    
    This PR aims to support typed `StreamingQueryProgress` in 
`StreamingQuery.lastProgress` and `recentProgress`.
    
    - `lastProgress` now returns `StreamingQueryProgress?` (previously 
`String?`) and `recentProgress` returns `[StreamingQueryProgress]` (previously 
`[String]`).
    - The unused internal structs in `StreamingQueryManager.swift` are moved to 
a new file `StreamingQueryProgress.swift` and promoted to public `Sendable` and 
`Codable` types: `StreamingQueryProgress` (renamed from the typo 
`StreamingQueryProcess`), `SourceProgress`, `SinkProgress`, and 
`StateOperatorProgress`.
    - JSON decoding is lenient like the Scala/PySpark clients: unknown fields 
are ignored, missing fields fall back to defaults, and offset fields preserve 
arbitrary JSON as strings. The original JSON remains accessible via the `json` 
property.
    
    ### Why are the changes needed?
    
    For feature parity with the Scala and PySpark clients which return typed 
`StreamingQueryProgress` objects.
    
    ### Does this PR introduce _any_ user-facing change?
    
    Yes, `lastProgress` and `recentProgress` now return typed values instead of 
raw JSON strings. The raw JSON is still available via the `json` property.
    
    ### How was this patch tested?
    
    Pass the CIs with the newly added unit tests.
    
    ### Was this patch authored or co-authored using generative AI tooling?
    
    Generated-by: Claude Fable 5
    
    Closes #491 from dongjoon-hyun/SPARK-58653.
    
    Authored-by: Dongjoon Hyun <[email protected]>
    Signed-off-by: Dongjoon Hyun <[email protected]>
---
 .../Documentation.docc/SparkConnect.md             |   4 +
 Sources/SparkConnect/StreamingQuery.swift          |  11 +-
 Sources/SparkConnect/StreamingQueryManager.swift   |  78 ------
 Sources/SparkConnect/StreamingQueryProgress.swift  | 292 +++++++++++++++++++++
 .../StreamingQueryProgressTests.swift              | 162 ++++++++++++
 Tests/SparkConnectTests/StreamingQueryTests.swift  |  47 ++++
 6 files changed, 512 insertions(+), 82 deletions(-)

diff --git a/Sources/SparkConnect/Documentation.docc/SparkConnect.md 
b/Sources/SparkConnect/Documentation.docc/SparkConnect.md
index cfd40fb..815bba8 100644
--- a/Sources/SparkConnect/Documentation.docc/SparkConnect.md
+++ b/Sources/SparkConnect/Documentation.docc/SparkConnect.md
@@ -59,3 +59,7 @@ SparkConnect is a modern Swift library that provides a native 
interface to Apach
 - ``DataStreamWriter``
 - ``StreamingQuery``
 - ``StreamingQueryManager``
+- ``StreamingQueryProgress``
+- ``SourceProgress``
+- ``SinkProgress``
+- ``StateOperatorProgress``
diff --git a/Sources/SparkConnect/StreamingQuery.swift 
b/Sources/SparkConnect/StreamingQuery.swift
index 24cf1ed..5db54b3 100644
--- a/Sources/SparkConnect/StreamingQuery.swift
+++ b/Sources/SparkConnect/StreamingQuery.swift
@@ -113,22 +113,25 @@ public actor StreamingQuery: Sendable {
   /// Returns an array of the most recent ``StreamingQueryProgress`` updates 
for this query.
   /// The number of progress updates retained for each stream is configured by 
Spark session
   /// configuration `spark.sql.streaming.numRecentProgressUpdates`.
-  public var recentProgress: [String] {
+  public var recentProgress: [StreamingQueryProgress] {
     get async throws {
       let response = try await executeCommand(
         StreamingQueryCommand.OneOf_Command.recentProgress(true))
       let result = response.first!.streamingQueryCommandResult.recentProgress
-      return result.recentProgressJson
+      return try result.recentProgressJson.map { try 
StreamingQueryProgress.fromJson($0) }
     }
   }
 
   /// Returns the most recent ``StreamingQueryProgress`` update of this 
streaming query.
-  public var lastProgress: String? {
+  public var lastProgress: StreamingQueryProgress? {
     get async throws {
       let response = try await executeCommand(
         StreamingQueryCommand.OneOf_Command.lastProgress(true))
       let result = response.first!.streamingQueryCommandResult.recentProgress
-      return result.recentProgressJson.first
+      guard let json = result.recentProgressJson.first else {
+        return nil
+      }
+      return try StreamingQueryProgress.fromJson(json)
     }
   }
 
diff --git a/Sources/SparkConnect/StreamingQueryManager.swift 
b/Sources/SparkConnect/StreamingQueryManager.swift
index 441cd19..bdac470 100644
--- a/Sources/SparkConnect/StreamingQueryManager.swift
+++ b/Sources/SparkConnect/StreamingQueryManager.swift
@@ -22,84 +22,6 @@ import FoundationEssentials
 import Foundation
 #endif
 
-/// Information about progress made for a source in the execution of a 
``StreamingQuery``
-/// during a trigger. See ``StreamingQueryProgress`` for more information.
-public struct SourceProgress: Sendable {
-  let description: String
-  let startOffset: String
-  let endOffset: String
-  let latestOffset: String
-  let numInputRows: Int64
-  let inputRowsPerSecond: Double
-  let processedRowsPerSecond: Double
-  let metrics: [String: String] = [:]
-}
-
-/// Information about progress made for a sink in the execution of a 
``StreamingQuery``
-/// during a trigger. See ``StreamingQueryProgress`` for more information.
-public struct SinkProgress: Sendable {
-  let description: String
-  let numOutputRows: Int64
-  let metrics: [String: String] = [:]
-
-  init(_ description: String) {
-    self.description = description
-    self.numOutputRows = -1
-  }
-
-  init(_ description: String, _ numOutputRows: Int64) {
-    self.description = description
-    self.numOutputRows = numOutputRows
-  }
-}
-
-/// Information about updates made to stateful operators in a 
``StreamingQuery``
-/// during a trigger. See ``StreamingQueryProgress`` for more information.
-public struct StateOperatorProgress: Sendable {
-  let operatorName: String
-  let numRowsTotal: Int64
-  let numRowsUpdated: Int64
-  let allUpdatesTimeMs: Int64
-  let numRowsRemoved: Int64
-  let allRemovalsTimeMs: Int64
-  let commitTimeMs: Int64
-  let memoryUsedBytes: Int64
-  let numRowsDroppedByWatermark: Int64
-  let numShufflePartitions: Int64
-  let numStateStoreInstances: Int64
-  let customMetrics: [String: Int64]
-}
-
-/// Information about progress made in the execution of a ``StreamingQuery``
-/// during a trigger. Each event relates to processing done for a single 
trigger of
-/// the streaming query. Events are emitted even when no new data is available 
to be processed.
-public struct StreamingQueryProcess {
-  let id: UUID
-  let runId: UUID
-  let name: String
-  let timestamp: String
-  let batchId: Int64
-  let batchDuration: Int64
-  let durationMs: [String: Int64]
-  let eventTime: [String: String]
-  let stateOperators: [StateOperatorProgress]
-  let sources: [SourceProgress]
-  let sink: SinkProgress
-  let observedMetrics: [String: Row]
-
-  func numInputRows() -> Int64 {
-    return sources.map { $0.numInputRows }.reduce(0, +)
-  }
-
-  func inputRowsPerSecond() -> Double {
-    return sources.map { $0.inputRowsPerSecond }.reduce(0, +)
-  }
-
-  func processedRowsPerSecond() -> Double {
-    return sources.map { $0.processedRowsPerSecond }.reduce(0, +)
-  }
-}
-
 /// A class to manage all the ``StreamingQuery``s active in a ``SparkSession``.
 public actor StreamingQueryManager {
   let sparkSession: SparkSession
diff --git a/Sources/SparkConnect/StreamingQueryProgress.swift 
b/Sources/SparkConnect/StreamingQueryProgress.swift
new file mode 100644
index 0000000..ec1c4fa
--- /dev/null
+++ b/Sources/SparkConnect/StreamingQueryProgress.swift
@@ -0,0 +1,292 @@
+//
+// 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 JSON value used to preserve arbitrary JSON fragments (e.g. source 
offsets) as strings.
+private enum JSONValue: Decodable, Sendable {
+  case null
+  case bool(Bool)
+  case int(Int64)
+  case double(Double)
+  case string(String)
+  case array([JSONValue])
+  case object([String: JSONValue])
+
+  init(from decoder: any Decoder) throws {
+    let container = try decoder.singleValueContainer()
+    if container.decodeNil() {
+      self = .null
+    } else if let value = try? container.decode(Bool.self) {
+      self = .bool(value)
+    } else if let value = try? container.decode(Int64.self) {
+      self = .int(value)
+    } else if let value = try? container.decode(Double.self) {
+      self = .double(value)
+    } else if let value = try? container.decode(String.self) {
+      self = .string(value)
+    } else if let value = try? container.decode([JSONValue].self) {
+      self = .array(value)
+    } else {
+      self = .object(try container.decode([String: JSONValue].self))
+    }
+  }
+
+  /// A compact JSON text of this value with alphabetically sorted object keys.
+  var compactString: String {
+    switch self {
+    case .null: return "null"
+    case .bool(let value): return value ? "true" : "false"
+    case .int(let value): return String(value)
+    case .double(let value): return String(value)
+    case .string(let value): return JSONValue.escape(value)
+    case .array(let values):
+      return "[" + values.map { $0.compactString }.joined(separator: ",") + "]"
+    case .object(let values):
+      let members = values.keys.sorted().map { 
"\(JSONValue.escape($0)):\(values[$0]!.compactString)" }
+      return "{" + members.joined(separator: ",") + "}"
+    }
+  }
+
+  /// A string form following the Spark clients' offset handling; a JSON 
string is returned as-is
+  /// and any other JSON value is converted to its compact JSON text.
+  var offsetString: String {
+    if case .string(let value) = self {
+      return value
+    }
+    return compactString
+  }
+
+  private static func escape(_ string: String) -> String {
+    var result = "\""
+    for scalar in string.unicodeScalars {
+      switch scalar {
+      case "\"": result += "\\\""
+      case "\\": result += "\\\\"
+      case "\n": result += "\\n"
+      case "\r": result += "\\r"
+      case "\t": result += "\\t"
+      default:
+        if scalar.value < 0x20 {
+          let hex = String(scalar.value, radix: 16)
+          result += "\\u" + String(repeating: "0", count: 4 - hex.count) + hex
+        } else {
+          result.unicodeScalars.append(scalar)
+        }
+      }
+    }
+    return result + "\""
+  }
+}
+
+/// Decodes a `Double` that servers may emit as a quoted string like `"NaN"` 
or omit entirely.
+private func decodeLenientDouble<Key: CodingKey>(
+  _ container: KeyedDecodingContainer<Key>, _ key: Key
+) -> Double {
+  if let value = try? container.decode(Double.self, forKey: key) {
+    return value
+  }
+  if let value = try? container.decode(String.self, forKey: key), let double = 
Double(value) {
+    return double
+  }
+  return .nan
+}
+
+/// Information about progress made for a source in the execution of a 
``StreamingQuery``
+/// during a trigger. See ``StreamingQueryProgress`` for more information.
+public struct SourceProgress: Sendable, Codable {
+  /// Description of the source.
+  public let description: String
+  /// The starting offset for data being read, as a JSON string.
+  public let startOffset: String
+  /// The ending offset for data being read, as a JSON string.
+  public let endOffset: String
+  /// The latest offset from this source, as a JSON string.
+  public let latestOffset: String
+  /// The number of records read from this source.
+  public let numInputRows: Int64
+  /// The rate at which data is arriving from this source, `nan` if not 
available.
+  public let inputRowsPerSecond: Double
+  /// The rate at which data from this source is being processed by Spark, 
`nan` if not available.
+  public let processedRowsPerSecond: Double
+  /// Custom metrics of this source.
+  public let metrics: [String: String]
+
+  public init(from decoder: any Decoder) throws {
+    let container = try decoder.container(keyedBy: CodingKeys.self)
+    self.description = try container.decodeIfPresent(String.self, forKey: 
.description) ?? ""
+    self.startOffset =
+      container.contains(.startOffset)
+      ? try container.decode(JSONValue.self, forKey: 
.startOffset).offsetString : ""
+    self.endOffset =
+      container.contains(.endOffset)
+      ? try container.decode(JSONValue.self, forKey: .endOffset).offsetString 
: ""
+    self.latestOffset =
+      container.contains(.latestOffset)
+      ? try container.decode(JSONValue.self, forKey: 
.latestOffset).offsetString : ""
+    self.numInputRows = try container.decodeIfPresent(Int64.self, forKey: 
.numInputRows) ?? 0
+    self.inputRowsPerSecond = decodeLenientDouble(container, 
.inputRowsPerSecond)
+    self.processedRowsPerSecond = decodeLenientDouble(container, 
.processedRowsPerSecond)
+    self.metrics = try container.decodeIfPresent([String: String].self, 
forKey: .metrics) ?? [:]
+  }
+}
+
+/// Information about progress made for a sink in the execution of a 
``StreamingQuery``
+/// during a trigger. See ``StreamingQueryProgress`` for more information.
+public struct SinkProgress: Sendable, Codable {
+  /// Description of the sink.
+  public let description: String
+  /// Number of rows written to the sink, `-1` if not available.
+  public let numOutputRows: Int64
+  /// Custom metrics of this sink.
+  public let metrics: [String: String]
+
+  public init(from decoder: any Decoder) throws {
+    let container = try decoder.container(keyedBy: CodingKeys.self)
+    self.description = try container.decodeIfPresent(String.self, forKey: 
.description) ?? ""
+    self.numOutputRows = try container.decodeIfPresent(Int64.self, forKey: 
.numOutputRows) ?? -1
+    self.metrics = try container.decodeIfPresent([String: String].self, 
forKey: .metrics) ?? [:]
+  }
+}
+
+/// Information about updates made to stateful operators in a 
``StreamingQuery``
+/// during a trigger. See ``StreamingQueryProgress`` for more information.
+public struct StateOperatorProgress: Sendable, Codable {
+  public let operatorName: String
+  public let numRowsTotal: Int64
+  public let numRowsUpdated: Int64
+  public let allUpdatesTimeMs: Int64
+  public let numRowsRemoved: Int64
+  public let allRemovalsTimeMs: Int64
+  public let commitTimeMs: Int64
+  public let memoryUsedBytes: Int64
+  public let numRowsDroppedByWatermark: Int64
+  public let numShufflePartitions: Int64
+  public let numStateStoreInstances: Int64
+  public let customMetrics: [String: Int64]
+
+  public init(from decoder: any Decoder) throws {
+    let container = try decoder.container(keyedBy: CodingKeys.self)
+    self.operatorName = try container.decodeIfPresent(String.self, forKey: 
.operatorName) ?? ""
+    self.numRowsTotal = try container.decodeIfPresent(Int64.self, forKey: 
.numRowsTotal) ?? 0
+    self.numRowsUpdated = try container.decodeIfPresent(Int64.self, forKey: 
.numRowsUpdated) ?? 0
+    self.allUpdatesTimeMs =
+      try container.decodeIfPresent(Int64.self, forKey: .allUpdatesTimeMs) ?? 0
+    self.numRowsRemoved = try container.decodeIfPresent(Int64.self, forKey: 
.numRowsRemoved) ?? 0
+    self.allRemovalsTimeMs =
+      try container.decodeIfPresent(Int64.self, forKey: .allRemovalsTimeMs) ?? 0
+    self.commitTimeMs = try container.decodeIfPresent(Int64.self, forKey: 
.commitTimeMs) ?? 0
+    self.memoryUsedBytes = try container.decodeIfPresent(Int64.self, forKey: 
.memoryUsedBytes) ?? 0
+    self.numRowsDroppedByWatermark =
+      try container.decodeIfPresent(Int64.self, forKey: 
.numRowsDroppedByWatermark) ?? 0
+    self.numShufflePartitions =
+      try container.decodeIfPresent(Int64.self, forKey: .numShufflePartitions) 
?? 0
+    self.numStateStoreInstances =
+      try container.decodeIfPresent(Int64.self, forKey: 
.numStateStoreInstances) ?? 0
+    self.customMetrics =
+      try container.decodeIfPresent([String: Int64].self, forKey: 
.customMetrics) ?? [:]
+  }
+}
+
+/// Information about progress made in the execution of a ``StreamingQuery``
+/// during a trigger. Each event relates to processing done for a single 
trigger of
+/// the streaming query. Events are emitted even when no new data is available 
to be processed.
+public struct StreamingQueryProgress: Sendable, Codable {
+  /// A unique query id that persists across restarts. See 
``StreamingQuery/id``.
+  public let id: UUID
+  /// A query id that is unique for every start/restart. See 
``StreamingQuery/runId``.
+  public let runId: UUID
+  /// User-specified name of the query, `nil` if not specified.
+  public let name: String?
+  /// Beginning time of the trigger in ISO8601 format, i.e. UTC timestamps.
+  public let timestamp: String
+  /// A unique id for the current batch of data being processed.
+  public let batchId: Int64
+  /// The process duration of each batch.
+  public let batchDuration: Int64
+  /// The amount of time taken to perform various operations in milliseconds.
+  public let durationMs: [String: Int64]
+  /// Statistics of event time seen in this batch, e.g. 
`max`/`min`/`avg`/`watermark`.
+  public let eventTime: [String: String]
+  /// Information about operators in the query that store state.
+  public let stateOperators: [StateOperatorProgress]
+  /// Detailed statistics on data being read from each of the streaming 
sources.
+  public let sources: [SourceProgress]
+  /// Information about progress made for the sink.
+  public let sink: SinkProgress
+  /// Observed metrics of this query, keyed by the observation name with the 
metric row
+  /// preserved as a JSON string.
+  public let observedMetrics: [String: String]
+  /// The original JSON representation of this progress received from the 
server.
+  public internal(set) var json: String = ""
+
+  /// The aggregate (across all sources) number of records processed in a 
trigger.
+  public var numInputRows: Int64 {
+    return sources.map { $0.numInputRows }.reduce(0, +)
+  }
+
+  /// The aggregate (across all sources) rate of data arriving.
+  public var inputRowsPerSecond: Double {
+    return sources.map { $0.inputRowsPerSecond }.reduce(0, +)
+  }
+
+  /// The aggregate (across all sources) rate at which Spark is processing 
data.
+  public var processedRowsPerSecond: Double {
+    return sources.map { $0.processedRowsPerSecond }.reduce(0, +)
+  }
+
+  enum CodingKeys: String, CodingKey {
+    case id, runId, name, timestamp, batchId, batchDuration, durationMs, 
eventTime
+    case stateOperators, sources, sink, observedMetrics
+  }
+
+  public init(from decoder: any Decoder) throws {
+    let container = try decoder.container(keyedBy: CodingKeys.self)
+    self.id = try container.decode(UUID.self, forKey: .id)
+    self.runId = try container.decode(UUID.self, forKey: .runId)
+    self.name = try container.decodeIfPresent(String.self, forKey: .name)
+    self.timestamp = try container.decode(String.self, forKey: .timestamp)
+    self.batchId = try container.decode(Int64.self, forKey: .batchId)
+    self.batchDuration = try container.decodeIfPresent(Int64.self, forKey: 
.batchDuration) ?? 0
+    self.durationMs =
+      try container.decodeIfPresent([String: Int64].self, forKey: .durationMs) 
?? [:]
+    self.eventTime =
+      try container.decodeIfPresent([String: String].self, forKey: .eventTime) 
?? [:]
+    self.stateOperators =
+      try container.decodeIfPresent([StateOperatorProgress].self, forKey: 
.stateOperators) ?? []
+    self.sources = try container.decodeIfPresent([SourceProgress].self, 
forKey: .sources) ?? []
+    self.sink = try container.decode(SinkProgress.self, forKey: .sink)
+    self.observedMetrics =
+      try container.decodeIfPresent([String: JSONValue].self, forKey: 
.observedMetrics)?
+      .mapValues { $0.compactString } ?? [:]
+  }
+
+  /// Creates a ``StreamingQueryProgress`` from the given JSON string.
+  /// Unknown fields are ignored and most of the missing fields fall back to 
default values.
+  /// - Parameter json: A JSON string emitted by a Spark Connect server.
+  /// - Returns: A ``StreamingQueryProgress``.
+  public static func fromJson(_ json: String) throws -> StreamingQueryProgress 
{
+    var progress = try JSONDecoder().decode(StreamingQueryProgress.self, from: 
Data(json.utf8))
+    progress.json = json
+    return progress
+  }
+}
diff --git a/Tests/SparkConnectTests/StreamingQueryProgressTests.swift 
b/Tests/SparkConnectTests/StreamingQueryProgressTests.swift
new file mode 100644
index 0000000..8794658
--- /dev/null
+++ b/Tests/SparkConnectTests/StreamingQueryProgressTests.swift
@@ -0,0 +1,162 @@
+//
+// 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
+import Testing
+
+@testable import SparkConnect
+
+/// A test suite for ``StreamingQueryProgress`` JSON decoding
+struct StreamingQueryProgressTests {
+
+  @Test
+  func fromJson() throws {
+    let json = """
+      {
+        "id": "33bd4ba3-79b2-4f0e-8d1c-2ccb44e29d4e",
+        "runId": "68ea3dd5-8b8e-4290-8d78-4b0a3f0e4a52",
+        "name": "myQuery",
+        "timestamp": "2026-08-07T00:00:00.000Z",
+        "batchId": 2,
+        "batchDuration": 1000,
+        "durationMs": {"addBatch": 100, "triggerExecution": 245},
+        "eventTime": {"watermark": "2026-08-07T00:00:00.000Z"},
+        "stateOperators": [{
+          "operatorName": "stateStoreSave",
+          "numRowsTotal": 3,
+          "numRowsUpdated": 1,
+          "allUpdatesTimeMs": 10,
+          "numRowsRemoved": 0,
+          "allRemovalsTimeMs": 0,
+          "commitTimeMs": 5,
+          "memoryUsedBytes": 100,
+          "numRowsDroppedByWatermark": 0,
+          "numShufflePartitions": 5,
+          "numStateStoreInstances": 5,
+          "customMetrics": {"loadedMapCacheHitCount": 2}
+        }],
+        "sources": [{
+          "description": "FileStreamSource[file:/tmp/input]",
+          "startOffset": "0",
+          "endOffset": "1",
+          "latestOffset": "1",
+          "numInputRows": 10,
+          "inputRowsPerSecond": 12.5,
+          "processedRowsPerSecond": 25.0,
+          "metrics": {"avgOffsetsBehindLatest": "0.0"}
+        }],
+        "sink": {"description": "FileSink[file:/tmp/output]", "numOutputRows": 
10},
+        "observedMetrics": {"event": {"c1": 1, "c2": 2}}
+      }
+      """
+    let progress = try StreamingQueryProgress.fromJson(json)
+    #expect(progress.id == UUID(uuidString: 
"33bd4ba3-79b2-4f0e-8d1c-2ccb44e29d4e"))
+    #expect(progress.runId == UUID(uuidString: 
"68ea3dd5-8b8e-4290-8d78-4b0a3f0e4a52"))
+    #expect(progress.name == "myQuery")
+    #expect(progress.timestamp == "2026-08-07T00:00:00.000Z")
+    #expect(progress.batchId == 2)
+    #expect(progress.batchDuration == 1000)
+    #expect(progress.durationMs == ["addBatch": 100, "triggerExecution": 245])
+    #expect(progress.eventTime == ["watermark": "2026-08-07T00:00:00.000Z"])
+    #expect(progress.stateOperators.count == 1)
+    #expect(progress.stateOperators[0].operatorName == "stateStoreSave")
+    #expect(progress.stateOperators[0].numRowsTotal == 3)
+    #expect(progress.stateOperators[0].customMetrics == 
["loadedMapCacheHitCount": 2])
+    #expect(progress.sources.count == 1)
+    #expect(progress.sources[0].description == 
"FileStreamSource[file:/tmp/input]")
+    #expect(progress.sources[0].startOffset == "0")
+    #expect(progress.sources[0].endOffset == "1")
+    #expect(progress.sources[0].numInputRows == 10)
+    #expect(progress.sources[0].inputRowsPerSecond == 12.5)
+    #expect(progress.sources[0].metrics == ["avgOffsetsBehindLatest": "0.0"])
+    #expect(progress.sink.description == "FileSink[file:/tmp/output]")
+    #expect(progress.sink.numOutputRows == 10)
+    #expect(progress.observedMetrics == ["event": "{\"c1\":1,\"c2\":2}"])
+    #expect(progress.numInputRows == 10)
+    #expect(progress.inputRowsPerSecond == 12.5)
+    #expect(progress.processedRowsPerSecond == 25.0)
+    #expect(progress.json == json)
+  }
+
+  @Test
+  func fromJsonWithMissingFields() throws {
+    let json = """
+      {
+        "id": "33bd4ba3-79b2-4f0e-8d1c-2ccb44e29d4e",
+        "runId": "68ea3dd5-8b8e-4290-8d78-4b0a3f0e4a52",
+        "name": null,
+        "timestamp": "2026-08-07T00:00:00.000Z",
+        "batchId": 0,
+        "unknownField": {"ignored": true},
+        "sink": {"description": "ForeachBatchSink"}
+      }
+      """
+    let progress = try StreamingQueryProgress.fromJson(json)
+    #expect(progress.name == nil)
+    #expect(progress.batchDuration == 0)
+    #expect(progress.durationMs.isEmpty)
+    #expect(progress.eventTime.isEmpty)
+    #expect(progress.stateOperators.isEmpty)
+    #expect(progress.sources.isEmpty)
+    #expect(progress.sink.description == "ForeachBatchSink")
+    #expect(progress.sink.numOutputRows == -1)
+    #expect(progress.sink.metrics.isEmpty)
+    #expect(progress.observedMetrics.isEmpty)
+    #expect(progress.numInputRows == 0)
+  }
+
+  @Test
+  func fromJsonWithObjectOffsetsAndNaN() throws {
+    let json = """
+      {
+        "id": "33bd4ba3-79b2-4f0e-8d1c-2ccb44e29d4e",
+        "runId": "68ea3dd5-8b8e-4290-8d78-4b0a3f0e4a52",
+        "timestamp": "2026-08-07T00:00:00.000Z",
+        "batchId": 0,
+        "sources": [{
+          "description": "KafkaV2[Subscribe[topic-0]]",
+          "startOffset": {"topic-0": {"0": 1}},
+          "endOffset": {"topic-0": {"0": 2}},
+          "latestOffset": null,
+          "numInputRows": 1,
+          "inputRowsPerSecond": "NaN"
+        }],
+        "sink": {"description": "MemorySink"}
+      }
+      """
+    let progress = try StreamingQueryProgress.fromJson(json)
+    #expect(progress.sources[0].startOffset == "{\"topic-0\":{\"0\":1}}")
+    #expect(progress.sources[0].endOffset == "{\"topic-0\":{\"0\":2}}")
+    #expect(progress.sources[0].latestOffset == "null")
+    #expect(progress.sources[0].inputRowsPerSecond.isNaN)
+    #expect(progress.sources[0].processedRowsPerSecond.isNaN)
+    #expect(progress.inputRowsPerSecond.isNaN)
+  }
+
+  @Test
+  func fromJsonWithInvalidJson() throws {
+    #expect(throws: Error.self) {
+      try StreamingQueryProgress.fromJson("invalid")
+    }
+  }
+}
diff --git a/Tests/SparkConnectTests/StreamingQueryTests.swift 
b/Tests/SparkConnectTests/StreamingQueryTests.swift
index ae2ad54..69be862 100644
--- a/Tests/SparkConnectTests/StreamingQueryTests.swift
+++ b/Tests/SparkConnectTests/StreamingQueryTests.swift
@@ -92,4 +92,51 @@ struct StreamingQueryTests {
 
     await spark.stop()
   }
+
+  @Test
+  func progress() async throws {
+    let spark = try await SparkSession.builder.getOrCreate()
+
+    // Prepare directories
+    let input = "/tmp/input-" + UUID().uuidString
+    let checkpoint = "/tmp/checkpoint-" + UUID().uuidString
+    let output = "/tmp/output-" + UUID().uuidString
+    try await spark.range(2025).write.orc(input)
+
+    // Start a streaming query
+    let query =
+      try await spark
+      .readStream
+      .schema("id LONG")
+      .orc(input)
+      .writeStream
+      .queryName("progress-test")
+      .option("checkpointLocation", checkpoint)
+      .outputMode("append")
+      .format("orc")
+      .trigger(Trigger.ProcessingTime(1000))
+      .start(output)
+    // Wait for processing
+    try await Task.sleep(nanoseconds: 2_000_000_000)
+
+    let progress = try #require(try await query.lastProgress)
+    #expect(await query.id == progress.id)
+    #expect(await query.runId == progress.runId)
+    #expect(progress.name == "progress-test")
+    #expect(progress.batchId >= 0)
+    #expect(progress.batchDuration >= 0)
+    #expect(progress.timestamp.isEmpty == false)
+    #expect(progress.durationMs.isEmpty == false)
+    #expect(progress.sources.count == 1)
+    #expect(progress.sources[0].description.contains("FileStreamSource"))
+    #expect(progress.sink.description.contains("FileSink"))
+    #expect(progress.json.contains("\"runId\""))
+
+    let recentProgress = try await query.recentProgress
+    #expect(recentProgress.isEmpty == false)
+    #expect(recentProgress.allSatisfy { $0.runId == progress.runId })
+
+    try await query.stop()
+    await spark.stop()
+  }
 }


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

Reply via email to