haiyangsun-db commented on code in PR #56702: URL: https://github.com/apache/spark/pull/56702#discussion_r3583989410
########## udf/worker/grpc/src/main/scala/org/apache/spark/udf/worker/grpc/GrpcWorkerSession.scala: ########## @@ -0,0 +1,816 @@ +/* + * 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, 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 the next + * input batch and sends its `DataRequest`. Nothing is sent until the iterator + * is consumed, and input flows one batch per output pull. The gRPC callback + * thread only receives output. + * + * '''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 control-message and + // terminator delivery on the whole channel. HTTP/2 flow control bounds the + // wire and the consumer normally drains promptly, so it stays small. A + // stalled downstream can still grow it; the real fix is protocol-level + // back-pressure (out of scope here), 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]() + + // Latch fired when `InitResponse` (success or error) or a transport error + // arrives. init() blocks on this; until it fires we have no proof the + // worker actually accepted the session. + private val initLatch = new CountDownLatch(1) + + // The InitResponse the worker sent (success or error), captured so init() + // can return it. None until InitResponse arrives. + private val initResponse = new AtomicReference[Option[InitResponse]](None) + + // 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. 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 -- only an intent to record. 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 => + 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. Count down initLatch like every other + // terminal-settling path here (onError / onCompleted / each + // handleControl branch): if this arrives before InitResponse, doInit + // must fail fast with this cause rather than block until + // initResponseTimeoutMs and report a misleading "timed out" error. + completeTerminal(Termination.TransportFailed(new IllegalStateException( + s"unexpected response oneof: $other"))) + initLatch.countDown() + } + } + + override def onError(t: Throwable): Unit = { + // Transport-level failure: the stream is dead. No further writes are + // possible (reaching a terminal state closes the write side). Settle the + // terminal BEFORE counting down initLatch: init() blocks on that latch, + // and only await/countDown establish a happens-before edge, so a thread + // woken by the countDown must already be able to observe the terminal. + // That lets init() surface the transport cause instead of timing out + // after initResponseTimeoutMs with a misleading "timed out" message -- + // or, if it saw a transient non-terminal state, the defensive "init latch + // fired without an InitResponse or terminal" error. + completeTerminal(Termination.TransportFailed(t)) + initLatch.countDown() + } + + 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) { + completeTerminal(Termination.TransportFailed(new IllegalStateException( + "worker response stream closed without a terminator"))) + } + // Defensive: if onCompleted reached us before InitResponse, doInit is + // still blocked on initLatch and would otherwise time out. + initLatch.countDown() + } + } + + /** + * 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 + // Capture the InitResponse so init() can return it (or throw on its error). + initResponse.set(Some(resp)) + if (resp.hasError) { + initLatch.countDown() + // Per the protocol the engine follows an init error with Cancel; send it + // if the request stream is up. If the Cancel cannot be written -- e.g. a + // directExecutor worker delivered InitResponse reentrantly, before + // doInit published requestObserver -- settle the terminal here so doInit + // does not wait the full terminalTimeoutMs for a CancelResponse that can + // never arrive. (When the Cancel is sent, the CancelResponse settles the + // terminal instead.) + if (!sendCancelInternal(() => cancelWithReason("init failed")) && + !currentState.isTerminal) { + completeTerminal(Termination.Failed(resp.getError)) + } + } else { + // InitResponse OK: init succeeded. A terminal that arrived first (e.g. a + // racing transport error) must win, so only advance from the expected + // Initializing state; process() then opens the data phase (Streaming). + compareAndSetState(SessionState.Initializing, SessionState.Initialized) + initLatch.countDown() + } + + case UdfControlResponse.ControlCase.ERROR => + val err = ctrl.getError.getError + executionError.compareAndSet(None, Some(err)) + // ERROR before InitResponse is special: under directExecutor or with + // a fast worker, sendCancelInternal may run while requestObserver is + // still null (we're inside stream.onNext(initRequest) and have not + // yet published the field), so no Cancel reaches the wire and no + // CancelResponse will follow to settle the terminal. Settle it here + // (as Failed, since there is no proto terminator to carry) so doInit + // doesn't return as if init succeeded. "Init not yet resolved" is exactly + // "the stream has not reached Initialized"; completeTerminal is idempotent, + // so the data-phase ERROR -> Cancel -> CancelResponse path is unaffected. + if (!initResolved) { + completeTerminal(Termination.Failed(err)) + } + // Surface the error to doInit. Count down AFTER settling the terminal + // above: init() blocks on this latch and only await/countDown establish + // happens-before, so a woken doInit must be able to observe the terminal + // rather than a transient non-terminal state. Idempotent, so the normal + // data-phase path (latch already fired during init) is unaffected. + initLatch.countDown() + 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. + completeTerminal(Termination.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. + initLatch.countDown() + + 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. + completeTerminal(Termination.Cancelled(ctrl.getCancel)) + // Defensive: CANCEL before InitResponse unblocks doInit so it can + // surface the cancellation instead of timing out. + initLatch.countDown() + + case UdfControlResponse.ControlCase.CONTROL_NOT_SET => + completeTerminal(Termination.TransportFailed(new IllegalStateException( + "empty UdfControlResponse oneof"))) + initLatch.countDown() + } + + /** True once init has resolved (Initialized or beyond). */ + 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() + + // ---- 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") + completeTerminal(Termination.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 { + // The base advanced Created -> Initializing before calling doInit, so a + // directExecutor worker's InitResponse -- delivered reentrantly inside + // onNext -- finds Initializing and advances to Initialized. + 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 + completeTerminal(Termination.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 { + initLatch.await(initResponseTimeoutMs, TimeUnit.MILLISECONDS) + } 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. + completeTerminal(Termination.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. + completeTerminal(Termination.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) + } + + initResponse.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 + // initLatch.countDown() 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. + completeTerminal(Termination.Cancelled(CancelResponse.getDefaultInstance)) + return Termination.Cancelled(CancelResponse.getDefaultInstance) + } + // If the stream has not finished on its own, cancel anything in flight so + // the worker can clean up, then wait for the terminator. sendCancelInternal + // evaluates the cancel thunk only when it actually writes a Cancel, and at + // most once across all callers. + if (!currentState.isTerminal) { + sendCancelInternal(cancel) + try { + terminalLatch.await(terminalTimeoutMs, TimeUnit.MILLISECONDS) + } catch { + case _: InterruptedException => Thread.currentThread().interrupt() + } + } + // If the worker still has not settled (timeout or interrupt above), + // record a terminal so isWorkerSalvageable returns a definite answer + // and any other thread reading the state sees a stable value. + if (!currentState.isTerminal) { + completeTerminal(Termination.TransportFailed(new TimeoutException( + s"timed out waiting for stream terminator after ${terminalTimeoutMs}ms"))) + } + // Half-close the request side regardless of whether we sent Cancel or + // Finish earlier -- the worker's onCompleted handler relies on this to + // tear down the per-stream state cleanly. onCompleted on an + // already-torn-down stream throws and is caught, so this stays idempotent + // across repeat close() calls. + try { + requestLock.synchronized { requestObserver.onCompleted() } + } catch { + case NonFatal(e) => + logger.debug("Error half-closing UDF Execute stream", e) + } + // Derive the Termination from the settled terminal. close() is the + // finalizer/cleanup path and must NOT re-throw: a UDF / data-phase error is + // already surfaced while the result iterator is consumed, and an init error + // from init(). settledTermination returns the response proto for the clean + // terminators and a best-effort empty Cancelled for the failure terminals. + settledTermination + } + + // ---- Internal request helpers --------------------------------------------- + + /** + * Sends a Data or Finish request to the worker. Two invariants are + * checked inside [[requestLock]]: + * - terminal state: writes are unsafe (transport dead or terminal + * received). Throws, so the caller's terminal/exception path runs. + * - [[cancelRequested]]: a Cancel has been (or is about to be) sent. + * Silently no-ops so a Data/Finish never appears on the wire after + * Cancel; the caller's iterator falls through to the terminator wait. + * + * Init is NOT sent through this helper -- [[doInit]] writes Init + * directly under [[requestLock]] before publishing + * [[requestObserver]], so there is no path through which a concurrent + * cancel could send Cancel before Init. + */ + private def sendRequest(req: UdfRequest): Unit = + requestLock.synchronized { + if (currentState.isTerminal) { + throw new IllegalStateException( + "cannot send request: UDF Execute stream is already closed") + } + // Suppress the write when a cancel has been (or is about to be) flushed + // through this lock; that preserves the proto ordering invariant (no + // Data/Finish after Cancel). We test the flag rather than `return`-ing + // from the block: a non-local return out of this by-name `synchronized` + // body compiles to a thrown NonLocalReturnControl, which is brittle. + if (!cancelRequested.get()) { + requestObserver.onNext(req) + } + } + + /** + * Sends a `Cancel` control message, returning `true` iff a `Cancel` was + * actually written to the wire. Idempotent across ALL call sites (close()'s + * cancel, in-band cancels from `handleControl`'s INIT error / ERROR paths, + * and engine-internal cancels from the iterator) via [[cancelRequested]]: + * only the first caller puts a `Cancel` on the wire, and the `cancel` thunk is + * evaluated only by that caller and only when the message is actually sent -- + * so a side-effecting thunk (e.g. one carrying a client cancel callback) runs + * at most once. Setting [[cancelRequested]] also blocks subsequent Data/Finish + * writes from [[ProcessIterator]] (see [[sendRequest]]), preserving the proto + * invariant that nothing follows `Cancel` on the engine-to-worker side. + */ + private def sendCancelInternal(cancel: () => Cancel): Boolean = { + if (!cancelRequested.compareAndSet(false, true)) return false + if (requestObserver == null) return false + if (currentState.isTerminal) return false + try { + requestLock.synchronized { + // A terminal that arrived first must win; bail without writing. We + // compute the block's value rather than `return`-ing from this by-name + // `synchronized` body, whose non-local return would compile to a thrown + // NonLocalReturnControl that the surrounding NonFatal catch must not + // swallow -- brittle to rely on. Reads naturally as the block result. + val cur = currentState + if (cur.isTerminal) { + false + } else { + // Record that a Cancel is on the wire by advancing to Cancelling. + compareAndSetState(cur, SessionState.Cancelling) + requestObserver.onNext(UdfRequest.newBuilder() + .setControl(UdfControlRequest.newBuilder().setCancel(cancel()).build()) + .build()) + true + } + } + } catch { + case NonFatal(e) => + logger.debug(s"Cancel send failed (stream may already be torn down): ${e.getMessage}") + completeTerminal(Termination.TransportFailed(e)) + false + } + } + + private def awaitTerminal(): Unit = { + if (currentState.isTerminal) return + try { + if (!terminalLatch.await(terminalTimeoutMs, TimeUnit.MILLISECONDS)) { + completeTerminal(Termination.TransportFailed(new TimeoutException( + s"timed out waiting for stream terminator after ${terminalTimeoutMs}ms"))) + } + } catch { + case _: InterruptedException => + // Attribute the terminal to the interrupt, not a timeout: the wait was + // cut short, it did not exhaust terminalTimeoutMs. Reporting "timed + // out" here would misdiagnose an interrupted close as a slow worker. + Thread.currentThread().interrupt() + completeTerminal(Termination.TransportFailed( + new InterruptedException("interrupted while waiting for stream terminator"))) + } + } + + // ---- ProcessIterator ------------------------------------------------------ + + /** + * Iterator returned by [[doProcess]]. Drives the data phase of the + * stream end-to-end: each call to `hasNext` / `next` may send a + * `DataRequest` or `Finish` to the worker, and reads result batches + * out of [[outputQueue]] as the worker emits them. + * + * The iterator is single-threaded with respect to the engine, but + * coexists with the gRPC callback thread (which enqueues responses + * and may settle the state) and with any thread that finalizes via + * [[close]]. It drives the `Streaming -> Finishing` transition (sending + * `Finish` once input is exhausted, built lazily from the `finish` thunk). + */ Review Comment: fixed -- 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]
