haiyangsun-db commented on code in PR #56702:
URL: https://github.com/apache/spark/pull/56702#discussion_r3665892025


##########
udf/worker/grpc/src/main/scala/org/apache/spark/udf/worker/grpc/GrpcWorkerSession.scala:
##########
@@ -0,0 +1,933 @@
+/*
+ * 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.udf.worker.grpc
+
+import java.util.concurrent.{CountDownLatch, LinkedBlockingQueue, 
TimeoutException, TimeUnit}
+import java.util.concurrent.atomic.{AtomicBoolean, AtomicReference}
+
+import scala.util.control.NonFatal
+
+import io.grpc.{ConnectivityState, ManagedChannel}
+import io.grpc.stub.StreamObserver
+
+import org.apache.spark.annotation.Experimental
+import org.apache.spark.udf.worker.{Cancel, CancelResponse, DataRequest, 
DataResponse,
+  ExecutionError, Finish, FinishResponse, Init, InitResponse, 
UdfControlRequest,
+  UdfControlResponse, UdfRequest, UdfResponse, UdfWorkerGrpc}
+import org.apache.spark.udf.worker.core.{Termination, WorkerHandle, 
WorkerLogger, WorkerSession}
+import org.apache.spark.udf.worker.core.WorkerSession.SessionState
+import org.apache.spark.udf.worker.grpc.GrpcWorkerSession._
+
+/**
+ * :: Experimental ::
+ * gRPC implementation of [[WorkerSession]] for the `UdfWorker.Execute`
+ * bidirectional RPC.
+ *
+ * Drives one bidirectional `Execute` stream against the worker per the
+ * ordering invariants documented in `udf_message.proto`:
+ * {{{
+ *   Engine -> Worker:  Init -> (DataRequest)* -> Finish (Cancel)?
+ *                                              | Cancel
+ *   Worker -> Engine:  InitResponse -> (DataResponse)* ->
+ *                      (ErrorResponse)? -> (FinishResponse | CancelResponse)
+ * }}}
+ *
+ * Knows nothing about how the worker was provisioned (locally spawned,
+ * indirectly looked up, ...) -- the dispatcher constructs this with a
+ * [[WorkerHandle]] and channel; the base [[WorkerSession]] handles
+ * dispatcher-side cleanup on close.
+ *
+ * '''Driving model.''' Consumption-driven (Volcano / pull): the thread that
+ * consumes the [[doProcess]] result iterator is the one that pulls input and
+ * sends each `DataRequest`; the gRPC callback thread only receives output. It 
is
+ * pull-driven but not one-input-per-output -- `advance` sends the next input
+ * whenever the output queue is momentarily empty, so under async delivery it 
may
+ * push several input batches before any output is read (bounded by HTTP/2 flow
+ * control).
+ *
+ * '''State machine.''' This class does not keep its own state machine: it
+ * drives the single [[WorkerSession.SessionState]] owned by the base. The base
+ * advances `Created -> Initializing` (in `init`) and `Initialized -> 
Streaming`
+ * (in `process`); this class advances the protocol-event edges through
+ * [[compareAndSetState]] / [[completeTerminal]] as it exchanges messages:
+ * {{{
+ *   Initializing --(InitResponse ok)--> Initialized   [handleControl]
+ *   Streaming ----(Finish written)----> Finishing      [ProcessIterator]
+ *   <any non-terminal> --(Cancel written)--> Cancelling [sendCancelInternal]
+ *   <any non-terminal> --(terminator/error)--> terminal [completeTerminal]
+ * }}}
+ * The two clean terminals carry the worker's `FinishResponse` / 
`CancelResponse`
+ * (metrics + finish/cancel callback `data`/`error`) so [[close]] can return
+ * them. The only flag kept outside the machine is [[cancelRequested]]: a
+ * cancellation can be requested before the stream exists (so it cannot be a
+ * state transition yet), and it must both fast-fail the result iterator and
+ * suppress any in-flight Data/Finish.
+ *
+ * Threading:
+ *  - [[doInit]] is synchronous: sends `Init` and blocks on `InitResponse`,
+ *    returning it.
+ *  - [[doProcess]] returns an iterator. Input batches are forwarded inline
+ *    (the iterator's `next()` thread also sends `DataRequest`). Output
+ *    batches arrive via the response observer (gRPC callback thread) and
+ *    are consumed by the same iterator. A terminator (`FinishResponse`,
+ *    `CancelResponse`, `ErrorResponse`, gRPC stream error) is published
+ *    once.
+ *  - [[doClose]] is thread-safe and idempotent: it settles + returns the
+ *    terminator (cancelling in-flight work if the stream had not finished)
+ *    and half-closes the request side.
+ *
+ * TODO [SPARK-55278]: this class does not yet implement payload chunking;
+ * the entire [[Init.udf]] payload is sent inline. Chunking will be added
+ * when a UDF payload large enough to exceed gRPC's default message size
+ * limit is introduced.
+ *
+ * @param workerHandle dispatcher-side handle for releasing the worker on
+ *                     [[close]] (see [[WorkerSession]]).
+ * @param channel      a gRPC channel built and owned by the caller (the
+ *                     dispatcher). Not closed here -- the dispatcher tears it
+ *                     down via [[WorkerHandle]].
+ * @param logger       diagnostics. Defaults to [[WorkerLogger.NoOp]].
+ * @param initResponseTimeoutMs upper bound on the wait for `InitResponse`
+ *                              after [[doInit]] sends `Init`.
+ * @param terminalTimeoutMs     upper bound on the wait for a stream
+ *                              terminator (`FinishResponse`,
+ *                              `CancelResponse`, or `ErrorResponse`).
+ *                              Each output-queue poll resets this wait;
+ *                              see [[doProcess]] / `ProcessIterator`.
+ */
+@Experimental
+class GrpcWorkerSession(
+    workerHandle: WorkerHandle,
+    channel: ManagedChannel,
+    logger: WorkerLogger = WorkerLogger.NoOp,
+    initResponseTimeoutMs: Long = DEFAULT_INIT_RESPONSE_TIMEOUT_MS,
+    terminalTimeoutMs: Long = DEFAULT_TERMINAL_TIMEOUT_MS)
+  extends WorkerSession(workerHandle, logger) {
+
+  require(channel != null, "channel is required")
+
+  private val asyncStub = UdfWorkerGrpc.newStub(channel)
+
+  // Output batches from the worker, drained by the process() iterator.
+  // Intentionally unbounded: a bounded queue would block the gRPC callback
+  // (Netty event-loop) thread when full, stalling terminator/control delivery 
on
+  // the whole channel. HTTP/2 flow control bounds the wire and the consumer
+  // normally drains promptly; a stalled downstream can still grow it, but the 
fix
+  // is protocol-level back-pressure (out of scope), not bounding the queue.
+  //
+  // TODO [SPARK-57324]: expose queue depth as a metric (early warning for a
+  // stalled consumer).
+  private val outputQueue = new LinkedBlockingQueue[QueueItem]()
+
+  // The worker's `InitResponse` (success or error) coupled with the latch that
+  // init() blocks on. Fires when the InitResponse arrives (`complete`) or 
when a
+  // terminal settles first without one -- transport error, half-close, or a
+  // premature terminator (`signalWithoutValue`). Until it fires we have no 
proof
+  // the worker accepted the session.
+  //
+  // Settle-before-release rule (referenced from every callback that both 
settles
+  // a terminal and fires this latch): settle the terminal FIRST, then 
complete /
+  // signal. init() blocks on the latch, and only latch await/release 
establish a
+  // happens-before edge, so a woken init() is guaranteed to observe the 
terminal
+  // rather than a transient state. OneShotValue keeps that 
publish-then-release
+  // in one place instead of every caller remembering to count down after 
setting
+  // the reference.
+  private val initValue = new OneShotValue[InitResponse]
+
+  // Fired when the session reaches a terminal [[SessionState]]. doClose() and
+  // the init-error path block on this to drain the terminator.
+  private val terminalLatch = new CountDownLatch(1)
+
+  // Captures an ErrorResponse encountered during the data phase so that
+  // the CancelResponse terminator can attribute the failure to the original
+  // user / worker / protocol error rather than reporting a bare "Cancelled".
+  private val executionError = new 
AtomicReference[Option[ExecutionError]](None)
+
+  // Cancellation INTENT -- distinct from the `Cancelling` state, which is 
reached
+  // only once a Cancel is actually written to the wire 
([[sendCancelInternal]]).
+  // Intent is set first and can outrun (or never reach) that write, so
+  // `cancelRequested` does NOT imply state `Cancelling`. Kept outside the
+  // [[SessionState]] machine because a cancel can be requested before the 
stream
+  // exists (pre-init), where there is no wire transition to make yet. Used to 
(a)
+  // make cancel idempotent across all call sites, (b) fast-fail 
ProcessIterator
+  // on a pre-init cancel, and (c) suppress any Data/Finish that would 
otherwise
+  // race a Cancel onto the wire (re-read inside [[requestLock]]).
+  private val cancelRequested = new AtomicBoolean(false)
+
+  // gRPC requires serialized writes to a request StreamObserver.
+  private val requestLock = new Object
+
+  // Initialised in init() -- before that, close() is a no-op on the request
+  // side, which is exactly the contract the wrapping WorkerSession expects.
+  @volatile private var requestObserver: StreamObserver[UdfRequest] = _
+
+  private val responseObserver: StreamObserver[UdfResponse] = new 
StreamObserver[UdfResponse] {
+    override def onNext(response: UdfResponse): Unit = {
+      response.getResponseCase match {
+        case UdfResponse.ResponseCase.DATA =>
+          // A DataResponse before InitResponse violates the protocol 
(InitResponse
+          // must precede any DataResponse): fast-fail rather than enqueue it 
and let
+          // init() block to its timeout. Settle-before-release (see 
initValue).
+          if (!initResolved) {
+            Transitions.transportFailed(new IllegalStateException(
+              "worker sent a DataResponse before InitResponse"))
+            initValue.signalWithoutValue()
+          } else {
+            outputQueue.put(QueueItem.Batch(response.getData))
+          }
+
+        case UdfResponse.ResponseCase.CONTROL =>
+          handleControl(response.getControl)
+
+        case other =>
+          // A malformed response (empty / unknown oneof) is a terminal 
transport
+          // failure; fast-fail init the same way. Settle-before-release (see 
initValue).
+          Transitions.transportFailed(new IllegalStateException(
+            s"unexpected response oneof: $other"))
+          initValue.signalWithoutValue()
+      }
+    }
+
+    override def onError(t: Throwable): Unit = {
+      // Transport-level failure: the stream is dead, no further writes 
possible.
+      // Settle-before-release (see initValue) so init() surfaces the transport
+      // cause instead of the initResponseTimeoutMs "timed out" error.
+      Transitions.transportFailed(t)
+      initValue.signalWithoutValue()
+    }
+
+    override def onCompleted(): Unit = {
+      // Worker half-closed its side without sending a terminator 
(FinishResponse
+      // / CancelResponse). Treat as transport error so the engine sees a
+      // failure, not a silent end-of-stream.
+      if (!currentState.isTerminal) {
+        Transitions.transportFailed(new IllegalStateException(
+          "worker response stream closed without a terminator"))
+      }
+      // Defensive: if onCompleted reached us before InitResponse, doInit is
+      // still blocked on initValue and would otherwise time out.
+      initValue.signalWithoutValue()
+    }
+  }
+
+  /**
+   * Wakes the result iterator (blocked on [[outputQueue]]) and any thread
+   * waiting on [[terminalLatch]] when the base settles a terminal. Invoked 
once,
+   * by the caller that wins [[completeTerminal]].
+   */
+  override protected def onTerminalSettled(termination: Termination): Unit = {
+    outputQueue.put(QueueItem.EndOfStream)
+    terminalLatch.countDown()
+  }
+
+  private def handleControl(ctrl: UdfControlResponse): Unit = 
ctrl.getControlCase match {
+    case UdfControlResponse.ControlCase.INIT =>
+      val resp = ctrl.getInit
+      if (resp.hasError) {
+        // Attribute a subsequent close()'s terminator to the init error rather
+        // than a bare Cancelled, mirroring the data-phase ERROR branch.
+        executionError.compareAndSet(None, Some(resp.getError))
+        // Settle Failed(err) before the Cancel below (not after its 
CancelResponse):
+        // the sticky terminal makes close() report the init error, and the 
Cancel
+        // becomes best-effort worker cleanup -- so if it can't be written (a
+        // directExecutor worker delivered this reentrantly, before doInit 
published
+        // requestObserver), doInit still doesn't wait terminalTimeoutMs for a
+        // CancelResponse that can never arrive. Settle-before-release (see 
initValue).
+        Transitions.failed(resp.getError)
+        initValue.complete(resp)
+        sendCancelInternal(() => cancelWithReason("init failed"))
+      } else {
+        // InitResponse OK. Only advance from Initializing so a terminal that 
raced
+        // in (e.g. a transport error) still wins; process() then opens the 
data
+        // phase. Settle-before-release (see initValue): publish after the CAS.
+        Transitions.initAccepted()
+        initValue.complete(resp)
+      }
+
+    case UdfControlResponse.ControlCase.ERROR =>
+      val err = ctrl.getError.getError
+      executionError.compareAndSet(None, Some(err))
+      // Before init resolves, settle Failed here rather than rely on the 
Cancel
+      // below: sendCancelInternal may find requestObserver still null 
(reentrant
+      // delivery inside stream.onNext, before doInit published it), so no 
Cancel --
+      // hence no CancelResponse -- would settle the terminal, and doInit would
+      // return as if init succeeded. completeTerminal is idempotent, so the
+      // data-phase ERROR -> Cancel -> CancelResponse path is unaffected.
+      if (!initResolved) {
+        Transitions.failed(err)
+      }
+      // Settle-before-release (see initValue). No value to publish -- ERROR 
is not
+      // an InitResponse; on the data-phase path the latch already fired in 
init.
+      initValue.signalWithoutValue()
+      sendCancelInternal(() => cancelWithReason("aborting after 
ErrorResponse"))
+
+    case UdfControlResponse.ControlCase.FINISH =>
+      // The FinishResponse carries metrics + the finish-callback data/error.
+      // Keep it on the terminal so close() can return it; the iterator 
inspects
+      // its error field to decide whether to throw.
+      Transitions.finished(ctrl.getFinish)
+      // Defensive: FINISH before InitResponse is a worker protocol bug, but
+      // we should fail init fast rather than hang the 30s init timeout.
+      initValue.signalWithoutValue()
+
+    case UdfControlResponse.ControlCase.CANCEL =>
+      // The CancelResponse carries metrics + the cancel-callback error. Keep 
it
+      // on the terminal so close() can return it; any prior ErrorResponse is
+      // tracked in executionError and surfaced by the iterator.
+      Transitions.cancelled(ctrl.getCancel)
+      // Defensive: CANCEL before InitResponse unblocks doInit so it can
+      // surface the cancellation instead of timing out.
+      initValue.signalWithoutValue()
+
+    case UdfControlResponse.ControlCase.CONTROL_NOT_SET =>
+      Transitions.transportFailed(new IllegalStateException(
+        "empty UdfControlResponse oneof"))
+      initValue.signalWithoutValue()
+  }
+
+  /**
+   * True once init is no longer pending -- i.e. the stream is past 
`Initializing`.
+   * Not "init succeeded": a terminal (including a failure) also counts as 
resolved.
+   */
+  private def initResolved: Boolean = currentState match {
+    case SessionState.Created | SessionState.Initializing => false
+    case _ => true
+  }
+
+  private def cancelWithReason(reason: String): Cancel =
+    Cancel.newBuilder().setReason(reason).build()
+
+  /**
+   * The protocol transition graph in one place: names for the edges, not a
+   * second source of truth. Every edge acts on the single
+   * [[WorkerSession.SessionState]] owned by the base via 
[[compareAndSetState]]
+   * (non-terminal) or [[completeTerminal]] (terminal), so the base's CAS is 
the
+   * only synchronization and a terminal that arrived first always wins (the
+   * non-terminal CASes fail against it; [[completeTerminal]] is first-wins).
+   *
+   * Edges this class drives -- edge, method, then driver site(s) / thread (the
+   * base drives the API-call edges: `Created -> Initializing` in `init`,
+   * `Initialized -> Streaming` in `process`):
+   * {{{
+   *   Initializing -> Initialized     initAccepted    handleControl INIT-ok  
[gRPC cb]
+   *   Streaming -> Finishing          finishSent      advance branch 3       
[engine]
+   *   non-terminal -> Cancelling      cancelSentFrom  sendCancelInternal, iff 
a Cancel
+   *                                     reaches the wire  [gRPC cb | engine | 
init | close]
+   *   non-terminal -> Terminal        
finished/cancelled/failed/transportFailed
+   *                                     handleControl / doInit / doClose / 
advance / onError
+   * }}}
+   * `Cancelling` is reached only if a Cancel is actually written; a 
pre-stream or
+   * raced cancel goes straight to a `Cancelled`/`TransportFailed` terminal 
(see
+   * [[sendCancelInternal]], [[doClose]], `ProcessIterator`) or nowhere -- so
+   * `cancelRequested` (intent) does not imply state `Cancelling`.
+   */
+  private object Transitions {
+    /** `InitResponse` OK: `Initializing -> Initialized`. */
+    def initAccepted(): Boolean =
+      compareAndSetState(SessionState.Initializing, SessionState.Initialized)
+
+    /** Input exhausted and `Finish` written: `Streaming -> Finishing` (once). 
*/
+    def finishSent(): Boolean =
+      compareAndSetState(SessionState.Streaming, SessionState.Finishing)
+
+    /** `Cancel` written: `cur -> Cancelling`, from any non-terminal `cur`. */
+    def cancelSentFrom(cur: SessionState): Boolean =
+      !cur.isTerminal && compareAndSetState(cur, SessionState.Cancelling)
+
+    /** Clean terminal carrying the worker's `FinishResponse`. */
+    def finished(response: FinishResponse): Boolean =
+      completeTerminal(Termination.Finished(response))
+
+    /** Clean terminal carrying the worker's `CancelResponse`. */
+    def cancelled(response: CancelResponse): Boolean =
+      completeTerminal(Termination.Cancelled(response))
+
+    /** Failure terminal carrying a structured [[ExecutionError]]. */
+    def failed(error: ExecutionError): Boolean =
+      completeTerminal(Termination.Failed(error))
+
+    /** Failure terminal carrying a transport-level cause. */
+    def transportFailed(cause: Throwable): Boolean =
+      completeTerminal(Termination.TransportFailed(cause))

Review Comment:
   There's no Transitions.failed anymore - transportFailed and the terminal 
outcomes are now distinct by design



##########
udf/worker/grpc/src/main/scala/org/apache/spark/udf/worker/grpc/GrpcWorkerSession.scala:
##########
@@ -0,0 +1,933 @@
+/*
+ * 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.udf.worker.grpc
+
+import java.util.concurrent.{CountDownLatch, LinkedBlockingQueue, 
TimeoutException, TimeUnit}
+import java.util.concurrent.atomic.{AtomicBoolean, AtomicReference}
+
+import scala.util.control.NonFatal
+
+import io.grpc.{ConnectivityState, ManagedChannel}
+import io.grpc.stub.StreamObserver
+
+import org.apache.spark.annotation.Experimental
+import org.apache.spark.udf.worker.{Cancel, CancelResponse, DataRequest, 
DataResponse,
+  ExecutionError, Finish, FinishResponse, Init, InitResponse, 
UdfControlRequest,
+  UdfControlResponse, UdfRequest, UdfResponse, UdfWorkerGrpc}
+import org.apache.spark.udf.worker.core.{Termination, WorkerHandle, 
WorkerLogger, WorkerSession}
+import org.apache.spark.udf.worker.core.WorkerSession.SessionState
+import org.apache.spark.udf.worker.grpc.GrpcWorkerSession._
+
+/**
+ * :: Experimental ::
+ * gRPC implementation of [[WorkerSession]] for the `UdfWorker.Execute`
+ * bidirectional RPC.
+ *
+ * Drives one bidirectional `Execute` stream against the worker per the
+ * ordering invariants documented in `udf_message.proto`:
+ * {{{
+ *   Engine -> Worker:  Init -> (DataRequest)* -> Finish (Cancel)?
+ *                                              | Cancel
+ *   Worker -> Engine:  InitResponse -> (DataResponse)* ->
+ *                      (ErrorResponse)? -> (FinishResponse | CancelResponse)
+ * }}}
+ *
+ * Knows nothing about how the worker was provisioned (locally spawned,
+ * indirectly looked up, ...) -- the dispatcher constructs this with a
+ * [[WorkerHandle]] and channel; the base [[WorkerSession]] handles
+ * dispatcher-side cleanup on close.
+ *
+ * '''Driving model.''' Consumption-driven (Volcano / pull): the thread that
+ * consumes the [[doProcess]] result iterator is the one that pulls input and
+ * sends each `DataRequest`; the gRPC callback thread only receives output. It 
is
+ * pull-driven but not one-input-per-output -- `advance` sends the next input
+ * whenever the output queue is momentarily empty, so under async delivery it 
may
+ * push several input batches before any output is read (bounded by HTTP/2 flow
+ * control).
+ *
+ * '''State machine.''' This class does not keep its own state machine: it
+ * drives the single [[WorkerSession.SessionState]] owned by the base. The base
+ * advances `Created -> Initializing` (in `init`) and `Initialized -> 
Streaming`
+ * (in `process`); this class advances the protocol-event edges through
+ * [[compareAndSetState]] / [[completeTerminal]] as it exchanges messages:
+ * {{{
+ *   Initializing --(InitResponse ok)--> Initialized   [handleControl]
+ *   Streaming ----(Finish written)----> Finishing      [ProcessIterator]
+ *   <any non-terminal> --(Cancel written)--> Cancelling [sendCancelInternal]
+ *   <any non-terminal> --(terminator/error)--> terminal [completeTerminal]
+ * }}}
+ * The two clean terminals carry the worker's `FinishResponse` / 
`CancelResponse`
+ * (metrics + finish/cancel callback `data`/`error`) so [[close]] can return
+ * them. The only flag kept outside the machine is [[cancelRequested]]: a
+ * cancellation can be requested before the stream exists (so it cannot be a
+ * state transition yet), and it must both fast-fail the result iterator and
+ * suppress any in-flight Data/Finish.
+ *
+ * Threading:
+ *  - [[doInit]] is synchronous: sends `Init` and blocks on `InitResponse`,
+ *    returning it.
+ *  - [[doProcess]] returns an iterator. Input batches are forwarded inline
+ *    (the iterator's `next()` thread also sends `DataRequest`). Output
+ *    batches arrive via the response observer (gRPC callback thread) and
+ *    are consumed by the same iterator. A terminator (`FinishResponse`,
+ *    `CancelResponse`, `ErrorResponse`, gRPC stream error) is published
+ *    once.
+ *  - [[doClose]] is thread-safe and idempotent: it settles + returns the
+ *    terminator (cancelling in-flight work if the stream had not finished)
+ *    and half-closes the request side.
+ *
+ * TODO [SPARK-55278]: this class does not yet implement payload chunking;
+ * the entire [[Init.udf]] payload is sent inline. Chunking will be added
+ * when a UDF payload large enough to exceed gRPC's default message size
+ * limit is introduced.
+ *
+ * @param workerHandle dispatcher-side handle for releasing the worker on
+ *                     [[close]] (see [[WorkerSession]]).
+ * @param channel      a gRPC channel built and owned by the caller (the
+ *                     dispatcher). Not closed here -- the dispatcher tears it
+ *                     down via [[WorkerHandle]].
+ * @param logger       diagnostics. Defaults to [[WorkerLogger.NoOp]].
+ * @param initResponseTimeoutMs upper bound on the wait for `InitResponse`
+ *                              after [[doInit]] sends `Init`.
+ * @param terminalTimeoutMs     upper bound on the wait for a stream
+ *                              terminator (`FinishResponse`,
+ *                              `CancelResponse`, or `ErrorResponse`).
+ *                              Each output-queue poll resets this wait;
+ *                              see [[doProcess]] / `ProcessIterator`.
+ */
+@Experimental
+class GrpcWorkerSession(
+    workerHandle: WorkerHandle,
+    channel: ManagedChannel,
+    logger: WorkerLogger = WorkerLogger.NoOp,
+    initResponseTimeoutMs: Long = DEFAULT_INIT_RESPONSE_TIMEOUT_MS,
+    terminalTimeoutMs: Long = DEFAULT_TERMINAL_TIMEOUT_MS)
+  extends WorkerSession(workerHandle, logger) {
+
+  require(channel != null, "channel is required")
+
+  private val asyncStub = UdfWorkerGrpc.newStub(channel)
+
+  // Output batches from the worker, drained by the process() iterator.
+  // Intentionally unbounded: a bounded queue would block the gRPC callback
+  // (Netty event-loop) thread when full, stalling terminator/control delivery 
on
+  // the whole channel. HTTP/2 flow control bounds the wire and the consumer
+  // normally drains promptly; a stalled downstream can still grow it, but the 
fix
+  // is protocol-level back-pressure (out of scope), not bounding the queue.
+  //
+  // TODO [SPARK-57324]: expose queue depth as a metric (early warning for a
+  // stalled consumer).
+  private val outputQueue = new LinkedBlockingQueue[QueueItem]()
+
+  // The worker's `InitResponse` (success or error) coupled with the latch that
+  // init() blocks on. Fires when the InitResponse arrives (`complete`) or 
when a
+  // terminal settles first without one -- transport error, half-close, or a
+  // premature terminator (`signalWithoutValue`). Until it fires we have no 
proof
+  // the worker accepted the session.
+  //
+  // Settle-before-release rule (referenced from every callback that both 
settles
+  // a terminal and fires this latch): settle the terminal FIRST, then 
complete /
+  // signal. init() blocks on the latch, and only latch await/release 
establish a
+  // happens-before edge, so a woken init() is guaranteed to observe the 
terminal
+  // rather than a transient state. OneShotValue keeps that 
publish-then-release
+  // in one place instead of every caller remembering to count down after 
setting
+  // the reference.
+  private val initValue = new OneShotValue[InitResponse]
+
+  // Fired when the session reaches a terminal [[SessionState]]. doClose() and
+  // the init-error path block on this to drain the terminator.
+  private val terminalLatch = new CountDownLatch(1)
+
+  // Captures an ErrorResponse encountered during the data phase so that
+  // the CancelResponse terminator can attribute the failure to the original
+  // user / worker / protocol error rather than reporting a bare "Cancelled".
+  private val executionError = new 
AtomicReference[Option[ExecutionError]](None)
+
+  // Cancellation INTENT -- distinct from the `Cancelling` state, which is 
reached
+  // only once a Cancel is actually written to the wire 
([[sendCancelInternal]]).
+  // Intent is set first and can outrun (or never reach) that write, so
+  // `cancelRequested` does NOT imply state `Cancelling`. Kept outside the
+  // [[SessionState]] machine because a cancel can be requested before the 
stream
+  // exists (pre-init), where there is no wire transition to make yet. Used to 
(a)
+  // make cancel idempotent across all call sites, (b) fast-fail 
ProcessIterator
+  // on a pre-init cancel, and (c) suppress any Data/Finish that would 
otherwise
+  // race a Cancel onto the wire (re-read inside [[requestLock]]).
+  private val cancelRequested = new AtomicBoolean(false)
+
+  // gRPC requires serialized writes to a request StreamObserver.
+  private val requestLock = new Object
+
+  // Initialised in init() -- before that, close() is a no-op on the request
+  // side, which is exactly the contract the wrapping WorkerSession expects.
+  @volatile private var requestObserver: StreamObserver[UdfRequest] = _
+
+  private val responseObserver: StreamObserver[UdfResponse] = new 
StreamObserver[UdfResponse] {
+    override def onNext(response: UdfResponse): Unit = {
+      response.getResponseCase match {
+        case UdfResponse.ResponseCase.DATA =>
+          // A DataResponse before InitResponse violates the protocol 
(InitResponse
+          // must precede any DataResponse): fast-fail rather than enqueue it 
and let
+          // init() block to its timeout. Settle-before-release (see 
initValue).
+          if (!initResolved) {
+            Transitions.transportFailed(new IllegalStateException(
+              "worker sent a DataResponse before InitResponse"))
+            initValue.signalWithoutValue()
+          } else {
+            outputQueue.put(QueueItem.Batch(response.getData))
+          }
+
+        case UdfResponse.ResponseCase.CONTROL =>
+          handleControl(response.getControl)
+
+        case other =>
+          // A malformed response (empty / unknown oneof) is a terminal 
transport
+          // failure; fast-fail init the same way. Settle-before-release (see 
initValue).
+          Transitions.transportFailed(new IllegalStateException(
+            s"unexpected response oneof: $other"))
+          initValue.signalWithoutValue()
+      }
+    }
+
+    override def onError(t: Throwable): Unit = {
+      // Transport-level failure: the stream is dead, no further writes 
possible.
+      // Settle-before-release (see initValue) so init() surfaces the transport
+      // cause instead of the initResponseTimeoutMs "timed out" error.
+      Transitions.transportFailed(t)
+      initValue.signalWithoutValue()
+    }
+
+    override def onCompleted(): Unit = {
+      // Worker half-closed its side without sending a terminator 
(FinishResponse
+      // / CancelResponse). Treat as transport error so the engine sees a
+      // failure, not a silent end-of-stream.
+      if (!currentState.isTerminal) {
+        Transitions.transportFailed(new IllegalStateException(
+          "worker response stream closed without a terminator"))
+      }
+      // Defensive: if onCompleted reached us before InitResponse, doInit is
+      // still blocked on initValue and would otherwise time out.
+      initValue.signalWithoutValue()
+    }
+  }
+
+  /**
+   * Wakes the result iterator (blocked on [[outputQueue]]) and any thread
+   * waiting on [[terminalLatch]] when the base settles a terminal. Invoked 
once,
+   * by the caller that wins [[completeTerminal]].
+   */
+  override protected def onTerminalSettled(termination: Termination): Unit = {
+    outputQueue.put(QueueItem.EndOfStream)
+    terminalLatch.countDown()
+  }
+
+  private def handleControl(ctrl: UdfControlResponse): Unit = 
ctrl.getControlCase match {
+    case UdfControlResponse.ControlCase.INIT =>
+      val resp = ctrl.getInit
+      if (resp.hasError) {
+        // Attribute a subsequent close()'s terminator to the init error rather
+        // than a bare Cancelled, mirroring the data-phase ERROR branch.
+        executionError.compareAndSet(None, Some(resp.getError))
+        // Settle Failed(err) before the Cancel below (not after its 
CancelResponse):
+        // the sticky terminal makes close() report the init error, and the 
Cancel
+        // becomes best-effort worker cleanup -- so if it can't be written (a
+        // directExecutor worker delivered this reentrantly, before doInit 
published
+        // requestObserver), doInit still doesn't wait terminalTimeoutMs for a
+        // CancelResponse that can never arrive. Settle-before-release (see 
initValue).
+        Transitions.failed(resp.getError)
+        initValue.complete(resp)
+        sendCancelInternal(() => cancelWithReason("init failed"))
+      } else {
+        // InitResponse OK. Only advance from Initializing so a terminal that 
raced
+        // in (e.g. a transport error) still wins; process() then opens the 
data
+        // phase. Settle-before-release (see initValue): publish after the CAS.
+        Transitions.initAccepted()
+        initValue.complete(resp)
+      }
+
+    case UdfControlResponse.ControlCase.ERROR =>
+      val err = ctrl.getError.getError
+      executionError.compareAndSet(None, Some(err))
+      // Before init resolves, settle Failed here rather than rely on the 
Cancel
+      // below: sendCancelInternal may find requestObserver still null 
(reentrant
+      // delivery inside stream.onNext, before doInit published it), so no 
Cancel --
+      // hence no CancelResponse -- would settle the terminal, and doInit would
+      // return as if init succeeded. completeTerminal is idempotent, so the
+      // data-phase ERROR -> Cancel -> CancelResponse path is unaffected.
+      if (!initResolved) {
+        Transitions.failed(err)
+      }
+      // Settle-before-release (see initValue). No value to publish -- ERROR 
is not
+      // an InitResponse; on the data-phase path the latch already fired in 
init.
+      initValue.signalWithoutValue()
+      sendCancelInternal(() => cancelWithReason("aborting after 
ErrorResponse"))
+
+    case UdfControlResponse.ControlCase.FINISH =>
+      // The FinishResponse carries metrics + the finish-callback data/error.
+      // Keep it on the terminal so close() can return it; the iterator 
inspects
+      // its error field to decide whether to throw.
+      Transitions.finished(ctrl.getFinish)
+      // Defensive: FINISH before InitResponse is a worker protocol bug, but
+      // we should fail init fast rather than hang the 30s init timeout.
+      initValue.signalWithoutValue()
+
+    case UdfControlResponse.ControlCase.CANCEL =>
+      // The CancelResponse carries metrics + the cancel-callback error. Keep 
it
+      // on the terminal so close() can return it; any prior ErrorResponse is
+      // tracked in executionError and surfaced by the iterator.
+      Transitions.cancelled(ctrl.getCancel)
+      // Defensive: CANCEL before InitResponse unblocks doInit so it can
+      // surface the cancellation instead of timing out.
+      initValue.signalWithoutValue()
+
+    case UdfControlResponse.ControlCase.CONTROL_NOT_SET =>
+      Transitions.transportFailed(new IllegalStateException(
+        "empty UdfControlResponse oneof"))
+      initValue.signalWithoutValue()
+  }
+
+  /**
+   * True once init is no longer pending -- i.e. the stream is past 
`Initializing`.
+   * Not "init succeeded": a terminal (including a failure) also counts as 
resolved.
+   */
+  private def initResolved: Boolean = currentState match {
+    case SessionState.Created | SessionState.Initializing => false
+    case _ => true
+  }
+
+  private def cancelWithReason(reason: String): Cancel =
+    Cancel.newBuilder().setReason(reason).build()
+
+  /**
+   * The protocol transition graph in one place: names for the edges, not a
+   * second source of truth. Every edge acts on the single
+   * [[WorkerSession.SessionState]] owned by the base via 
[[compareAndSetState]]
+   * (non-terminal) or [[completeTerminal]] (terminal), so the base's CAS is 
the
+   * only synchronization and a terminal that arrived first always wins (the
+   * non-terminal CASes fail against it; [[completeTerminal]] is first-wins).
+   *
+   * Edges this class drives -- edge, method, then driver site(s) / thread (the
+   * base drives the API-call edges: `Created -> Initializing` in `init`,
+   * `Initialized -> Streaming` in `process`):
+   * {{{
+   *   Initializing -> Initialized     initAccepted    handleControl INIT-ok  
[gRPC cb]
+   *   Streaming -> Finishing          finishSent      advance branch 3       
[engine]
+   *   non-terminal -> Cancelling      cancelSentFrom  sendCancelInternal, iff 
a Cancel
+   *                                     reaches the wire  [gRPC cb | engine | 
init | close]
+   *   non-terminal -> Terminal        
finished/cancelled/failed/transportFailed
+   *                                     handleControl / doInit / doClose / 
advance / onError
+   * }}}
+   * `Cancelling` is reached only if a Cancel is actually written; a 
pre-stream or
+   * raced cancel goes straight to a `Cancelled`/`TransportFailed` terminal 
(see
+   * [[sendCancelInternal]], [[doClose]], `ProcessIterator`) or nowhere -- so
+   * `cancelRequested` (intent) does not imply state `Cancelling`.
+   */
+  private object Transitions {
+    /** `InitResponse` OK: `Initializing -> Initialized`. */
+    def initAccepted(): Boolean =
+      compareAndSetState(SessionState.Initializing, SessionState.Initialized)
+
+    /** Input exhausted and `Finish` written: `Streaming -> Finishing` (once). 
*/
+    def finishSent(): Boolean =
+      compareAndSetState(SessionState.Streaming, SessionState.Finishing)
+
+    /** `Cancel` written: `cur -> Cancelling`, from any non-terminal `cur`. */
+    def cancelSentFrom(cur: SessionState): Boolean =
+      !cur.isTerminal && compareAndSetState(cur, SessionState.Cancelling)
+
+    /** Clean terminal carrying the worker's `FinishResponse`. */
+    def finished(response: FinishResponse): Boolean =
+      completeTerminal(Termination.Finished(response))
+
+    /** Clean terminal carrying the worker's `CancelResponse`. */
+    def cancelled(response: CancelResponse): Boolean =
+      completeTerminal(Termination.Cancelled(response))
+
+    /** Failure terminal carrying a structured [[ExecutionError]]. */
+    def failed(error: ExecutionError): Boolean =
+      completeTerminal(Termination.Failed(error))
+
+    /** Failure terminal carrying a transport-level cause. */
+    def transportFailed(cause: Throwable): Boolean =
+      completeTerminal(Termination.TransportFailed(cause))
+  }
+
+  // ---- WorkerSession hooks ------------------------------------------------
+
+  override protected def doInit(message: Init): InitResponse = {
+    // Fail fast if the channel is already shut down. Without this check,
+    // asyncStub.execute(...) would still succeed and the failure would
+    // surface ~initResponseTimeoutMs later as a misleading "InitResponse
+    // timed out" error.
+    if (channel.getState(false) == ConnectivityState.SHUTDOWN) {
+      val ex = new IllegalStateException("gRPC channel is shut down")
+      Transitions.transportFailed(ex)
+      throw new GrpcWorkerSessionException("UDF worker channel is closed", ex)
+    }
+    // Open the stream as a local first; only publish `requestObserver`
+    // AFTER the Init has been put on the wire. close()'s cancel reads
+    // `requestObserver` outside [[requestLock]] and returns early when
+    // it is null, so this ordering prevents a concurrent cancel from
+    // sneaking a Cancel ahead of Init.
+    val stream = asyncStub.execute(responseObserver)
+    val initRequest = UdfRequest.newBuilder()
+      .setControl(UdfControlRequest.newBuilder().setInit(message).build())
+      .build()
+    try {
+      requestLock.synchronized {
+        // Reentrant delivery: a directExecutor worker (one whose gRPC 
callbacks run
+        // synchronously on the caller's thread) can deliver InitResponse -- 
and thus
+        // run responseObserver/handleControl -- from *inside* this 
stream.onNext,
+        // before requestObserver below is published. The base advanced
+        // Created -> Initializing before calling doInit, so that reentrant
+        // InitResponse still finds Initializing and advances to Initialized; 
the
+        // handleControl error paths guard the still-null requestObserver. 
This is
+        // the scenario the "before doInit published requestObserver" comments 
mean.
+        stream.onNext(initRequest)
+        requestObserver = stream
+      }
+    } catch {
+      case NonFatal(e) =>
+        // Expose the stream so a subsequent close() can still attempt a
+        // best-effort half-close; the terminal already reflects the failure.
+        requestObserver = stream
+        Transitions.transportFailed(e)
+        // Surface as GrpcWorkerSessionException so the engine integration 
layer
+        // (which catches that type and wraps it) sees a uniform init-failure
+        // exception rather than the raw transport error.
+        throw new GrpcWorkerSessionException("UDF worker stream failed during 
init", e)
+    }
+
+    val responded = try {
+      initValue.await(initResponseTimeoutMs)
+    } catch {
+      case _: InterruptedException =>
+        Thread.currentThread().interrupt()
+        sendCancelInternal(() => cancelWithReason("interrupted during init"))
+        // Make sure close() does not block waiting for a terminator that
+        // will never arrive on this thread's behalf.
+        Transitions.transportFailed(
+          new InterruptedException("interrupted while waiting for 
InitResponse"))
+        throw new InterruptedException("interrupted while waiting for 
InitResponse")
+    }
+
+    if (!responded) {
+      sendCancelInternal(() => cancelWithReason("InitResponse timed out"))
+      val timeout = new TimeoutException(
+        s"timed out waiting for InitResponse after ${initResponseTimeoutMs}ms")
+      // Settle the terminal so a subsequent close() does not stall for a
+      // second `terminalTimeoutMs` waiting for a worker that already missed
+      // its init deadline.
+      Transitions.transportFailed(timeout)
+      // Surface as GrpcWorkerSessionException (carrying the timeout cause) so
+      // the engine integration layer that catches that type can wrap it.
+      throw new GrpcWorkerSessionException(
+        s"timed out waiting for InitResponse after 
${initResponseTimeoutMs}ms", timeout)
+    }
+
+    initValue.get match {
+      case Some(resp) if resp.hasError =>
+        // The protocol requires the engine to send Cancel after an init
+        // error and the worker to respond with CancelResponse. Drain it
+        // before throwing so we don't leave a dangling stream.
+        awaitTerminal()
+        throw new GrpcWorkerSessionException(
+          s"UDF worker init failed: ${describeError(resp.getError)}", 
resp.getError)
+      case Some(resp) =>
+        resp
+      case None =>
+        // No InitResponse arrived but the latch fired. The defensive
+        // initValue.signalWithoutValue() in handleControl / onError /
+        // onCompleted means we get here when the worker terminated the stream
+        // before sending InitResponse. Surface that as an init failure rather
+        // than letting the caller proceed as if init succeeded.
+        currentState match {
+          case SessionState.Terminal(Termination.TransportFailed(cause)) =>
+            throw new GrpcWorkerSessionException(
+              "UDF worker stream failed during init", cause)
+          case SessionState.Terminal(Termination.Failed(err)) =>
+            throw new GrpcWorkerSessionException(
+              s"UDF worker reported an error before init completed: " +
+                describeError(err), err)
+          case SessionState.Terminal(Termination.Cancelled(_)) =>
+            throw new GrpcWorkerSessionException(
+              "UDF worker stream was cancelled before init completed")
+          case SessionState.Terminal(Termination.Finished(_)) =>
+            throw new GrpcWorkerSessionException(
+              "UDF worker finished before init completed")
+          case other =>
+            throw new IllegalStateException(
+              s"init latch fired without an InitResponse or terminal: $other")
+        }
+    }
+  }
+
+  override protected def doProcess(
+      input: Iterator[DataRequest],
+      finish: () => Finish): Iterator[DataResponse] = {
+    // Init success is guaranteed by the base [[WorkerSession]] lifecycle: if
+    // doInit had failed it would have thrown and process() would never run.
+    new ProcessIterator(input, finish)
+  }
+
+  override protected def doClose(cancel: () => Cancel): Termination = {
+    if (requestObserver == null) {
+      // init() never put a stream on the wire (closed before/around init, or
+      // init threw before publishing). There is no protocol terminator; treat
+      // the session as cancelled-before-start. The base WorkerSession still
+      // releases the worker handle, so the worker is torn down.
+      Transitions.cancelled(CancelResponse.getDefaultInstance)
+      return Termination.Cancelled(CancelResponse.getDefaultInstance)

Review Comment:
   There's no Transitions.failed anymore -  transportFailed and the terminal 
outcomes are now  distinct by design



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]


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

Reply via email to