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 c627124  [SPARK-58543] Support reattachable execution with 
`ReattachExecute` and `ReleaseExecute`
c627124 is described below

commit c627124ccbc05927aa0af4a42924154a41f2e6ce
Author: Dongjoon Hyun <[email protected]>
AuthorDate: Tue Aug 4 07:12:17 2026 -0700

    [SPARK-58543] Support reattachable execution with `ReattachExecute` and 
`ReleaseExecute`
    
    ### What changes were proposed in this pull request?
    
    This PR supports reattachable execution in `SparkConnectClient` like the 
Scala side
    
`org.apache.spark.sql.connect.client.ExecutePlanResponseReattachableIterator` 
and the Python
    side 
`pyspark.sql.connect.client.reattach.ExecutePlanResponseReattachableIterator`.
    
    1. A new `executePlanWithReattach` helper executes `ExecutePlan` with
       `ReattachOptions.reattachable=true` and tracks `response_id`, 
`server_side_session_id`, and
       `ResultComplete` of the response stream.
    2. When the response stream is broken by a retriable RPC error (governed by 
the existing
       `RetryPolicy.defaultPolicy` with exponential backoff, reset whenever an 
attempt receives a
       new response), or ends gracefully without a `ResultComplete` message, 
the stream is resumed
       with `ReattachExecute` after the last received response on a fresh 
channel.
    3. When `ReattachExecute` fails with `INVALID_HANDLE.OPERATION_NOT_FOUND` or
       `INVALID_HANDLE.SESSION_NOT_FOUND` before receiving any response, the 
initial `ExecutePlan`
       didn't reach the server and is started over. After receiving responses, 
it is surfaced as
       the new `SparkConnectError.invalidState`, which is also thrown when the 
server side session
       ID changes in the middle of a stream.
    4. The server side buffer is released with a `release_all` `ReleaseExecute` 
when
       `ResultComplete` arrives, and in a best-effort way when the execution is 
abandoned after an
       error. Incremental `release_until` calls are a server-side optimization 
hint (the server
       auto-releases the sent responses beyond 
`spark.connect.execute.reattachable.observerRetryBufferSize`)
       and are left as a follow-up.
    5. All three `ExecutePlan` consumption paths (`DataFrame.execute`, 
`DataFrame.count`, and
       `SparkConnectClient.execute`) use the new helper, superseding the 
temporary
       retry-until-the-first-response pattern introduced by SPARK-58541.
    
    ### Why are the changes needed?
    
    `ExecutePlan` was a single non-resumable server stream. Once the first 
response arrived, any
    broken connection failed the whole query without a recovery path, and the 
temporary retry of
    SPARK-58541 could not help because re-executing the plan can have side 
effects. Reattachable
    execution is the mechanism the Scala and Python clients use by default 
(Spark 3.5+ servers),
    and this PR brings the Swift client to parity.
    
    ### Does this PR introduce _any_ user-facing change?
    
    Yes, executions now survive transient response stream breaks by resuming 
after the last
    received response instead of failing, and the server releases its response 
buffers explicitly
    at the end of each execution. Successful executions return the same results 
as before.
    
    ### How was this patch tested?
    
    Pass the CIs with the newly added `ReattachableExecuteTests` test suite 
covering the
    reattachable `ExecutePlan`, `ReattachExecute`, and `ReleaseExecute` request 
builders. All
    existing test suites pass with a live Spark Connect server 4.2.0, now 
exercising every
    `ExecutePlan` through the reattachable path. Note that a real mid-stream 
disconnection is not
    reproducible reliably in the integration tests (the server ends idle 
reattachable streams only
    after `spark.connect.execute.reattachable.senderMaxStreamDuration`, 2 
minutes by default), so
    the reattach path is verified indirectly through the graceful stream-end 
handling.
    
    ### Was this patch authored or co-authored using generative AI tooling?
    
    Generated-by: Claude Fable 5
    
    Closes #477 from dongjoon-hyun/SPARK-58543.
    
    Authored-by: Dongjoon Hyun <[email protected]>
    Signed-off-by: Dongjoon Hyun <[email protected]>
---
 Sources/SparkConnect/DataFrame+Actions.swift       | 144 +++++++--------
 Sources/SparkConnect/DataFrame.swift               |   3 +-
 .../SparkConnectClient+ReattachableExecute.swift   | 196 +++++++++++++++++++++
 Sources/SparkConnect/SparkConnectClient.swift      |  23 +--
 Sources/SparkConnect/SparkConnectError.swift       |   5 +-
 Sources/SparkConnect/TypeAliases.swift             |   3 +
 .../ReattachableExecuteTests.swift                 |  72 ++++++++
 7 files changed, 342 insertions(+), 104 deletions(-)

diff --git a/Sources/SparkConnect/DataFrame+Actions.swift 
b/Sources/SparkConnect/DataFrame+Actions.swift
index 845685e..89a004e 100644
--- a/Sources/SparkConnect/DataFrame+Actions.swift
+++ b/Sources/SparkConnect/DataFrame+Actions.swift
@@ -36,76 +36,65 @@ extension DataFrame {
 
   /// Execute the plan and try to fill `schema` and `batches`.
   private func execute() async throws {
-    // `ExecutePlan` can have side effects on the server. Retry only until the 
first
-    // response arrives because the operation is not started before that.
-    let received = Atomic(false)
-    try await withRetry(shouldRetry: { !received.load(ordering: .relaxed) && 
RetryPolicy.canRetry($0) }) {
-      // Clear all existing batches.
-      self.batches.removeAll()
-
-      try await withGPRC(retryable: false) { client in
-        let service = Spark_Connect_SparkConnectService.Client(wrapping: 
client)
-        try await 
service.executePlan(spark.client.getExecutePlanRequest(plan)) {
-          response in
-          for try await m in response.messages {
-            received.store(true, ordering: .relaxed)
-            if m.hasSchema {
-              // The original schema should arrive before ArrowBatches
-              await self.setSchema(m.schema)
-            }
-            let ipcStreamBytes = m.arrowBatch.data
-            if !ipcStreamBytes.isEmpty && m.arrowBatch.rowCount > 0 {
-              let IPC_CONTINUATION_TOKEN = Int32(-1)
-              let totalSize = Int64(ipcStreamBytes.count)
-              // Schema
-              guard totalSize >= 8,
-                ipcStreamBytes[0..<4].int32 == IPC_CONTINUATION_TOKEN
-              else {
-                throw SparkConnectError.InvalidArrowData
-              }
-              let schemaSize = Int64(ipcStreamBytes[4..<8].int32)
-              guard schemaSize >= 0, 8 + schemaSize + 4 <= totalSize else {
-                throw SparkConnectError.InvalidArrowData
-              }
-              let schema = Data(ipcStreamBytes[8..<(8 + schemaSize)])
-
-              // Arrow IPC Data
-              guard ipcStreamBytes[(8 + schemaSize)..<(8 + schemaSize + 
4)].int32
-                == IPC_CONTINUATION_TOKEN
-              else {
-                throw SparkConnectError.InvalidArrowData
-              }
-              var pos: Int64 = 8 + schemaSize + 4
-              guard pos + 4 <= totalSize else {
-                throw SparkConnectError.InvalidArrowData
-              }
-              let dataHeaderSize = Int64(ipcStreamBytes[pos..<(pos + 4)].int32)
-              pos += 4
-              guard dataHeaderSize >= 0, pos + dataHeaderSize + 8 <= totalSize 
else {
-                throw SparkConnectError.InvalidArrowData
-              }
-              let dataHeader = Data(ipcStreamBytes[pos..<(pos + 
dataHeaderSize)])
-              pos += dataHeaderSize
-              let dataBodySize = totalSize - pos - 8
-              let dataBody = Data(ipcStreamBytes[pos..<(pos + dataBodySize)])
-
-              // Read ArrowBatches
-              let reader = ArrowReader()
-              let arrowResult = ArrowReader.makeArrowReaderResult()
-              if case .failure(let error) = reader.fromMessage(
-                schema, dataBody: Data(), result: arrowResult)
-              {
-                throw error
-              }
-              if case .failure(let error) = reader.fromMessage(
-                dataHeader, dataBody: dataBody, result: arrowResult)
-              {
-                throw error
-              }
-              await self.addBatches(arrowResult.batches)
-            }
-          }
+    // Clear all existing batches.
+    self.batches.removeAll()
+
+    try await 
spark.client.executePlanWithReattach(spark.client.getExecutePlanRequest(plan)) {
+      m in
+      if m.hasSchema {
+        // The original schema should arrive before ArrowBatches
+        await self.setSchema(m.schema)
+      }
+      let ipcStreamBytes = m.arrowBatch.data
+      if !ipcStreamBytes.isEmpty && m.arrowBatch.rowCount > 0 {
+        let IPC_CONTINUATION_TOKEN = Int32(-1)
+        let totalSize = Int64(ipcStreamBytes.count)
+        // Schema
+        guard totalSize >= 8,
+          ipcStreamBytes[0..<4].int32 == IPC_CONTINUATION_TOKEN
+        else {
+          throw SparkConnectError.InvalidArrowData
+        }
+        let schemaSize = Int64(ipcStreamBytes[4..<8].int32)
+        guard schemaSize >= 0, 8 + schemaSize + 4 <= totalSize else {
+          throw SparkConnectError.InvalidArrowData
+        }
+        let schema = Data(ipcStreamBytes[8..<(8 + schemaSize)])
+
+        // Arrow IPC Data
+        guard ipcStreamBytes[(8 + schemaSize)..<(8 + schemaSize + 4)].int32
+          == IPC_CONTINUATION_TOKEN
+        else {
+          throw SparkConnectError.InvalidArrowData
         }
+        var pos: Int64 = 8 + schemaSize + 4
+        guard pos + 4 <= totalSize else {
+          throw SparkConnectError.InvalidArrowData
+        }
+        let dataHeaderSize = Int64(ipcStreamBytes[pos..<(pos + 4)].int32)
+        pos += 4
+        guard dataHeaderSize >= 0, pos + dataHeaderSize + 8 <= totalSize else {
+          throw SparkConnectError.InvalidArrowData
+        }
+        let dataHeader = Data(ipcStreamBytes[pos..<(pos + dataHeaderSize)])
+        pos += dataHeaderSize
+        let dataBodySize = totalSize - pos - 8
+        let dataBody = Data(ipcStreamBytes[pos..<(pos + dataBodySize)])
+
+        // Read ArrowBatches
+        let reader = ArrowReader()
+        let arrowResult = ArrowReader.makeArrowReaderResult()
+        if case .failure(let error) = reader.fromMessage(
+          schema, dataBody: Data(), result: arrowResult)
+        {
+          throw error
+        }
+        if case .failure(let error) = reader.fromMessage(
+          dataHeader, dataBody: dataBody, result: arrowResult)
+        {
+          throw error
+        }
+        await self.addBatches(arrowResult.batches)
       }
     }
   }
@@ -185,20 +174,9 @@ extension DataFrame {
   public func count() async throws -> Int64 {
     let counter = Atomic(Int64(0))
 
-    // `ExecutePlan` can have side effects on the server. Retry only until the 
first
-    // response arrives because the operation is not started before that.
-    let received = Atomic(false)
-    try await withRetry(shouldRetry: { !received.load(ordering: .relaxed) && 
RetryPolicy.canRetry($0) }) {
-      try await withGPRC(retryable: false) { client in
-        let service = Spark_Connect_SparkConnectService.Client(wrapping: 
client)
-        try await 
service.executePlan(spark.client.getExecutePlanRequest(plan)) {
-          response in
-          for try await m in response.messages {
-            received.store(true, ordering: .relaxed)
-            counter.add(m.arrowBatch.rowCount, ordering: .relaxed)
-          }
-        }
-      }
+    try await 
spark.client.executePlanWithReattach(spark.client.getExecutePlanRequest(plan)) {
+      m in
+      counter.add(m.arrowBatch.rowCount, ordering: .relaxed)
     }
     return counter.load(ordering: .relaxed)
   }
diff --git a/Sources/SparkConnect/DataFrame.swift 
b/Sources/SparkConnect/DataFrame.swift
index 754f148..26ee5cd 100644
--- a/Sources/SparkConnect/DataFrame.swift
+++ b/Sources/SparkConnect/DataFrame.swift
@@ -305,10 +305,9 @@ public actor DataFrame: Sendable {
   }
 
   func withGPRC<Result: Sendable>(
-    retryable: Bool = true,
     _ f: (GRPCClient<GRPCNIOTransportHTTP2.HTTP2ClientTransport.Posix>) async 
throws -> Result
   ) async throws -> Result {
-    try await withRetry(shouldRetry: { retryable && RetryPolicy.canRetry($0) 
}) {
+    try await withRetry {
       try await withGRPCClient(
         transport: .http2NIOPosix(
           target: .dns(host: spark.client.host, port: spark.client.port),
diff --git a/Sources/SparkConnect/SparkConnectClient+ReattachableExecute.swift 
b/Sources/SparkConnect/SparkConnectClient+ReattachableExecute.swift
new file mode 100644
index 0000000..2fc05a6
--- /dev/null
+++ b/Sources/SparkConnect/SparkConnectClient+ReattachableExecute.swift
@@ -0,0 +1,196 @@
+//
+// 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.
+//
+import GRPCCore
+import Synchronization
+
+/// The stream progress of a reattachable `ExecutePlan` execution, shared with 
the `@Sendable`
+/// response stream handlers.
+private final class ReattachableExecuteState: Sendable {
+  private struct Progress {
+    var lastResponseID: String? = nil
+    var serverSideSessionID: String? = nil
+    var responseCount = 0
+    var resultComplete = false
+  }
+
+  private let progress = Mutex(Progress())
+
+  /// Record a received response and assert that the server side session has 
not changed.
+  func record(_ response: ExecutePlanResponse) throws {
+    try progress.withLock { p in
+      if let id = p.serverSideSessionID, id != response.serverSideSessionID {
+        throw SparkConnectError.invalidState(
+          SparkConnectError.Details(
+            message:
+              "Server side session ID changed. Create a new SparkSession to 
continue. "
+              + "(Old: \(id), New: \(response.serverSideSessionID))"))
+      }
+      p.serverSideSessionID = response.serverSideSessionID
+      p.lastResponseID = response.responseID
+      p.responseCount += 1
+      if case .resultComplete = response.responseType {
+        p.resultComplete = true
+      }
+    }
+  }
+
+  var lastResponseID: String? { progress.withLock { $0.lastResponseID } }
+  var responseCount: Int { progress.withLock { $0.responseCount } }
+  var resultComplete: Bool { progress.withLock { $0.resultComplete } }
+}
+
+extension SparkConnectClient {
+  /// Return a copy of `request` whose `request_options` asks the server to 
keep the execution
+  /// reattachable with `ReattachExecute` after the response stream is broken.
+  static func getReattachableExecutePlanRequest(
+    _ request: ExecutePlanRequest
+  ) -> ExecutePlanRequest {
+    var reattachableRequest = request
+    var option = ExecutePlanRequest.RequestOption()
+    var reattachOptions = ReattachOptions()
+    reattachOptions.reattachable = true
+    option.reattachOptions = reattachOptions
+    reattachableRequest.requestOptions.append(option)
+    return reattachableRequest
+  }
+
+  /// Create a ``ReattachExecuteRequest`` resuming the response stream of 
`request` after
+  /// `lastResponseID`, or from the start if nil.
+  static func getReattachExecuteRequest(
+    _ request: ExecutePlanRequest, _ lastResponseID: String?
+  ) -> ReattachExecuteRequest {
+    var reattach = ReattachExecuteRequest()
+    reattach.sessionID = request.sessionID
+    reattach.userContext = request.userContext
+    reattach.operationID = request.operationID
+    reattach.clientType = request.clientType
+    if let lastResponseID {
+      reattach.lastResponseID = lastResponseID
+    }
+    return reattach
+  }
+
+  /// Create a ``ReleaseExecuteRequest`` releasing the whole operation of 
`request` on the server.
+  static func getReleaseAllRequest(_ request: ExecutePlanRequest) -> 
ReleaseExecuteRequest {
+    var release = ReleaseExecuteRequest()
+    release.sessionID = request.sessionID
+    release.userContext = request.userContext
+    release.operationID = request.operationID
+    release.clientType = request.clientType
+    release.releaseAll = ReleaseExecuteRequest.ReleaseAll()
+    return release
+  }
+
+  /// Execute `request` as a reattachable execution
+  /// like 
`org.apache.spark.sql.connect.client.ExecutePlanResponseReattachableIterator`.
+  ///
+  /// The plan is executed with `ReattachOptions.reattachable=true`. When the 
response stream is
+  /// broken by a retriable RPC error, or ends without a `ResultComplete` 
message, the stream is
+  /// resumed with `ReattachExecute` after the last received response. The 
retriable errors are
+  /// governed by ``RetryPolicy/defaultPolicy`` whose backoff is reset 
whenever an attempt
+  /// receives a new response. The server side buffer is released with a 
`release_all`
+  /// `ReleaseExecute` at the end.
+  /// - Parameters:
+  ///   - request: An ``ExecutePlanRequest`` to execute.
+  ///   - processResponse: A handler invoked for each ``ExecutePlanResponse`` 
in order.
+  func executePlanWithReattach(
+    _ request: ExecutePlanRequest,
+    _ processResponse: @Sendable @escaping (ExecutePlanResponse) async throws 
-> Void
+  ) async throws {
+    let initialRequest = Self.getReattachableExecutePlanRequest(request)
+    let state = ReattachableExecuteState()
+
+    @Sendable func consume(
+      _ response: GRPCCore.StreamingClientResponse<ExecutePlanResponse>
+    ) async throws {
+      for try await m in response.messages {
+        try state.record(m)
+        try await processResponse(m)
+      }
+    }
+
+    var retryState = RetryPolicyState(.defaultPolicy)
+    var responseCountAtLastError = 0
+    var reattach = false
+    while !state.resultComplete {
+      do {
+        try await withGPRC(retryable: false) { client in
+          let service = SparkConnectService.Client(wrapping: client)
+          if reattach {
+            let reattachRequest =
+              Self.getReattachExecuteRequest(initialRequest, 
state.lastResponseID)
+            try await service.reattachExecute(reattachRequest, onResponse: 
consume)
+          } else {
+            try await service.executePlan(initialRequest, onResponse: consume)
+          }
+          if state.resultComplete {
+            _ = try await 
service.releaseExecute(Self.getReleaseAllRequest(initialRequest))
+          }
+        }
+        // When the stream ended gracefully without `ResultComplete`, the 
server has more
+        // responses and the loop continues with `ReattachExecute`.
+        reattach = true
+      } catch {
+        reattach = true
+        let responseCount = state.responseCount
+        if Self.isOperationNotFound(error) {
+          guard responseCount == 0 else {
+            await releaseAll(initialRequest)
+            throw SparkConnectError.invalidState(
+              SparkConnectError.Details(
+                message:
+                  "OPERATION_NOT_FOUND on the server but responses were 
already received from it."))
+          }
+          // The initial `ExecutePlan` didn't reach the server. Start over 
with `ExecutePlan`.
+          reattach = false
+        } else if !RetryPolicy.canRetry(error) {
+          await releaseAll(initialRequest)
+          throw error
+        }
+        // An attempt which received new responses resets the backoff.
+        if responseCount > responseCountAtLastError {
+          responseCountAtLastError = responseCount
+          retryState = RetryPolicyState(.defaultPolicy)
+        }
+        guard !Task.isCancelled, let wait = retryState.nextAttempt(error) else 
{
+          await releaseAll(initialRequest)
+          throw error
+        }
+        try await Task.sleep(for: wait)
+      }
+    }
+  }
+
+  /// Inform the server to release the operation of `request` after giving up, 
ignoring failures
+  /// because the server releases abandoned executions by itself.
+  private func releaseAll(_ request: ExecutePlanRequest) async {
+    try? await withGPRC(retryable: false) { client in
+      let service = SparkConnectService.Client(wrapping: client)
+      _ = try await service.releaseExecute(Self.getReleaseAllRequest(request))
+    }
+  }
+
+  /// Return true if the error means that the operation of the initial 
`ExecutePlan` doesn't
+  /// exist on the server.
+  private static func isOperationNotFound(_ error: Error) -> Bool {
+    guard let error = error as? RPCError else { return false }
+    return error.message.contains("INVALID_HANDLE.OPERATION_NOT_FOUND")
+      || error.message.contains("INVALID_HANDLE.SESSION_NOT_FOUND")
+  }
+}
diff --git a/Sources/SparkConnect/SparkConnectClient.swift 
b/Sources/SparkConnect/SparkConnectClient.swift
index 79411ef..8449f4c 100644
--- a/Sources/SparkConnect/SparkConnectClient.swift
+++ b/Sources/SparkConnect/SparkConnectClient.swift
@@ -24,7 +24,6 @@ import Foundation
 import GRPCCore
 import GRPCNIOTransportHTTP2
 import GRPCProtobuf
-import Synchronization
 
 /// Conceptually the remote spark session that communicates with the server
 public actor SparkConnectClient {
@@ -858,23 +857,11 @@ public actor SparkConnectClient {
 
   @discardableResult
   func execute(_ sessionID: String, _ command: Command) async throws -> 
[ExecutePlanResponse] {
-    // `ExecutePlan` can have side effects on the server. Retry only until the 
first
-    // response arrives because the operation is not started before that.
-    let received = Atomic(false)
-    try await withRetry(shouldRetry: { !received.load(ordering: .relaxed) && 
RetryPolicy.canRetry($0) }) {
-      self.result.removeAll()
-      try await withGPRC(retryable: false) { client in
-        let service = SparkConnectService.Client(wrapping: client)
-        var plan = Plan()
-        plan.opType = .command(command)
-        try await service.executePlan(getExecutePlanRequest(plan)) {
-          response in
-          for try await m in response.messages {
-            received.store(true, ordering: .relaxed)
-            await self.addResponse(m)
-          }
-        }
-      }
+    self.result.removeAll()
+    var plan = Plan()
+    plan.opType = .command(command)
+    try await executePlanWithReattach(getExecutePlanRequest(plan)) { m in
+      await self.addResponse(m)
     }
     return result
   }
diff --git a/Sources/SparkConnect/SparkConnectError.swift 
b/Sources/SparkConnect/SparkConnectError.swift
index f45bb62..f20459b 100644
--- a/Sources/SparkConnect/SparkConnectError.swift
+++ b/Sources/SparkConnect/SparkConnectError.swift
@@ -55,6 +55,7 @@ public enum SparkConnectError: Error, Sendable, Equatable {
   case invalidArgument(Details)
   case invalidArrowData(Details)
   case invalidSessionID(Details)
+  case invalidState(Details)
   case invalidType(Details)
   case invalidViewName(Details)
   case localRelationTooLarge(Details)
@@ -75,6 +76,7 @@ public enum SparkConnectError: Error, Sendable, Equatable {
   public static var InvalidArgument: SparkConnectError { 
.invalidArgument(Details()) }
   public static var InvalidArrowData: SparkConnectError { 
.invalidArrowData(Details()) }
   public static var InvalidSessionID: SparkConnectError { 
.invalidSessionID(Details()) }
+  public static var InvalidState: SparkConnectError { .invalidState(Details()) 
}
   public static var InvalidType: SparkConnectError { .invalidType(Details()) }
   public static var InvalidViewName: SparkConnectError { 
.invalidViewName(Details()) }
   public static var LocalRelationTooLarge: SparkConnectError { 
.localRelationTooLarge(Details()) }
@@ -95,7 +97,7 @@ public enum SparkConnectError: Error, Sendable, Equatable {
     case .catalogNotFound(let details), .columnNotFound(let details),
       .dataSourceNotFound(let details), .invalidArgument(let details),
       .invalidArrowData(let details), .invalidSessionID(let details),
-      .invalidType(let details), .invalidViewName(let details),
+      .invalidState(let details), .invalidType(let details), 
.invalidViewName(let details),
       .localRelationTooLarge(let details), .outputTypeUnspecified(let details),
       .parseSyntaxError(let details), .schemaNotFound(let details),
       .sessionClosed(let details), .sqlConfNotFound(let details),
@@ -125,6 +127,7 @@ public enum SparkConnectError: Error, Sendable, Equatable {
     case .invalidArgument: return "invalidArgument"
     case .invalidArrowData: return "invalidArrowData"
     case .invalidSessionID: return "invalidSessionID"
+    case .invalidState: return "invalidState"
     case .invalidType: return "invalidType"
     case .invalidViewName: return "invalidViewName"
     case .localRelationTooLarge: return "localRelationTooLarge"
diff --git a/Sources/SparkConnect/TypeAliases.swift 
b/Sources/SparkConnect/TypeAliases.swift
index f3027df..1099931 100644
--- a/Sources/SparkConnect/TypeAliases.swift
+++ b/Sources/SparkConnect/TypeAliases.swift
@@ -52,7 +52,10 @@ typealias Plan = Spark_Connect_Plan
 typealias Project = Spark_Connect_Project
 typealias Range = Spark_Connect_Range
 typealias Read = Spark_Connect_Read
+typealias ReattachExecuteRequest = Spark_Connect_ReattachExecuteRequest
+typealias ReattachOptions = Spark_Connect_ReattachOptions
 typealias Relation = Spark_Connect_Relation
+typealias ReleaseExecuteRequest = Spark_Connect_ReleaseExecuteRequest
 typealias Repartition = Spark_Connect_Repartition
 typealias RepartitionByExpression = Spark_Connect_RepartitionByExpression
 typealias Sample = Spark_Connect_Sample
diff --git a/Tests/SparkConnectTests/ReattachableExecuteTests.swift 
b/Tests/SparkConnectTests/ReattachableExecuteTests.swift
new file mode 100644
index 0000000..41be4db
--- /dev/null
+++ b/Tests/SparkConnectTests/ReattachableExecuteTests.swift
@@ -0,0 +1,72 @@
+//
+// 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.
+//
+
+import Testing
+
+@testable import SparkConnect
+
+/// A test suite for the reattachable execution request builders
+@Suite(.serialized)
+struct ReattachableExecuteTests {
+  static func executePlanRequest() -> ExecutePlanRequest {
+    var request = ExecutePlanRequest()
+    request.clientType = "swift"
+    request.userContext = "user".toUserContext
+    request.sessionID = "session-id"
+    request.operationID = "operation-id"
+    return request
+  }
+
+  @Test
+  func reattachableExecutePlanRequest() {
+    let request = 
SparkConnectClient.getReattachableExecutePlanRequest(Self.executePlanRequest())
+    #expect(request.requestOptions.count == 1)
+    #expect(request.requestOptions[0].reattachOptions.reattachable)
+    #expect(request.sessionID == "session-id")
+    #expect(request.operationID == "operation-id")
+  }
+
+  @Test
+  func reattachExecuteRequest() {
+    let request =
+      SparkConnectClient.getReattachExecuteRequest(Self.executePlanRequest(), 
"response-id")
+    #expect(request.sessionID == "session-id")
+    #expect(request.userContext.userID == "user")
+    #expect(request.operationID == "operation-id")
+    #expect(request.clientType == "swift")
+    #expect(request.hasLastResponseID)
+    #expect(request.lastResponseID == "response-id")
+  }
+
+  @Test
+  func reattachExecuteRequestFromStart() {
+    let request = 
SparkConnectClient.getReattachExecuteRequest(Self.executePlanRequest(), nil)
+    #expect(!request.hasLastResponseID)
+  }
+
+  @Test
+  func releaseAllRequest() {
+    let request = 
SparkConnectClient.getReleaseAllRequest(Self.executePlanRequest())
+    #expect(request.sessionID == "session-id")
+    #expect(request.userContext.userID == "user")
+    #expect(request.operationID == "operation-id")
+    #expect(request.clientType == "swift")
+    #expect(request.release == .releaseAll(ReleaseExecuteRequest.ReleaseAll()))
+  }
+}


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

Reply via email to