Skip to content

[SPARK-57640] Implement gRPC WorkerSession and test it against an echo server. - #56702

Open
haiyangsun-db wants to merge 10 commits into
apache:masterfrom
haiyangsun-db:SPARK-57640
Open

[SPARK-57640] Implement gRPC WorkerSession and test it against an echo server.#56702
haiyangsun-db wants to merge 10 commits into
apache:masterfrom
haiyangsun-db:SPARK-57640

Conversation

@haiyangsun-db

@haiyangsun-db haiyangsun-db commented Jun 23, 2026

Copy link
Copy Markdown
Contributor

What changes were proposed in this pull request?

This PR adds GrpcWorkerSession, the engine-side WorkerSession implementation that drives one bidirectional gRPC Execute stream per UDF execution. It is the first concrete protocol implementation on top of the abstractions already in master (the WorkerSession SessionState machine, Termination, WorkerHandle, and the udf_message.proto / udf_service.proto split).

It is deliberately scoped to the session driver and its in-process tests, split out of the larger gRPC-backend change (#55683) so the concurrency-heavy core can be reviewed in isolation. The UDS transport, channel, and dispatcher follow in a second PR.

Added:

  • GrpcWorkerSession (udf/worker/grpc) — drives the Init -> DataRequest* -> Finish | Cancel / InitResponse -> DataResponse* -> terminator exchange. Design highlights:
    • No shadow state machine — it advances the single SessionState owned by the base WorkerSession along the protocol-event edges; the only out-of-band flag is cancelRequested (a cancel can be requested before the stream exists).
    • Consumption-driven (Volcano pull) — the thread consuming the result iterator sends the next input batch; the gRPC callback thread only receives.
    • All wire writes serialized under one requestLock, with cancelRequested enforcing the proto invariant "no Data/Finish after Cancel".
    • Every wait is timeout-bounded; the post-Finish terminal wait is per-poll (reset by each worker event), so the contract is "emit an event every terminalTimeoutMs", not "finish within it".
    • GrpcWorkerSessionException extends RuntimeException (not SparkRuntimeException) so the udf-worker modules stay free of a spark-core dependency; the engine integration layer wraps it.
  • GrpcWorkerSessionConcurrencySuite — in-process tests pinning the wire-ordering and fast-fail invariants.
  • grpc/pom.xml — adds the spark-udf-worker-core module dependency (the session needs core; the module previously depended only on udf-worker-proto).

Class relationship: GrpcWorkerSession depends only on a raw io.grpc.ManagedChannel, not on any transport/channel class — which is what lets it (and its tests) stand alone here, with the channel/dispatcher wiring landing in the follow-up.

Why are the changes needed?

The UDF worker protocol defines a language-agnostic execution surface but, prior to this work, had no engine-side driver to run it. GrpcWorkerSession is that driver — the piece that turns the protocol abstractions into an actual UDF execution over gRPC bidirectional streaming. Landing it separately from the transport and dispatcher keeps the most correctness-sensitive component (the concurrent stream/state-machine interaction) small and independently testable.

Does this PR introduce any user-facing change?

No. udf/worker is @Experimental and not wired into any Spark execution path; there are no production callers yet.

How was this patch tested?

  • New GrpcWorkerSessionConcurrencySuite (in-process gRPC via InProcessServerBuilder/InProcessChannelBuilder; no UDS, no subprocess) pins the invariants: a worker-emitted ErrorResponse / FinishResponse / CancelResponse / stream half-close / malformed (RESPONSE_NOT_SET) response before InitResponse all fail init() fast rather than hanging on the init timeout; repeated hasNext after exhaustion returns immediately; close() concurrent with process() settles cleanly. The fast-fail and data-phase paths are re-run under cross-thread (non-directExecutor) delivery so correctness does not silently depend on reentrant delivery.
  • The existing EchoProtocolSuite continues to exercise the full wire protocol end-to-end against an in-process echo worker.
  • Verified locally: build/sbt "udf-worker-grpc/testOnly *GrpcWorkerSessionConcurrencySuite *EchoProtocolSuite" — 2 suites, 33 tests, 0 failures.

Was this patch authored or co-authored using generative AI tooling?

Yes

@haiyangsun-db haiyangsun-db changed the title grpc session. [SPARK-57640] Implement gRPC WorkerSession and test it against an echo server. Jun 23, 2026
@haiyangsun-db
haiyangsun-db marked this pull request as ready for review June 23, 2026 13:19

@Yicong-Huang Yicong-Huang left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the effort, left some comments!

Correctness:
- Fast-fail init on a DataResponse received before InitResponse instead of
  enqueuing the stray batch and hanging until initResponseTimeoutMs.
- Guard input.hasNext (not just input.next) against exceptions, cancelling
  the stream before rethrowing so the worker is not stranded awaiting input.
- Guard the caller-supplied finish() thunk the same way.
- Carry the init error onto the terminal: the InitResponse-with-error path
  now settles Failed(err) before attempting Cancel, so close() returns
  Termination.Failed rather than a bare Cancelled.

Readability:
- Introduce OneShotValue to bundle the init latch and captured InitResponse
  so the publish-then-release ordering lives in one place.
- Centralize the protocol transition edges behind a Transitions object over
  the single SessionState (no behavior change).
- Correct the driving-model scaladoc: input is pull-driven, not strictly
  one-input-per-output.

Tests:
- Assert the WorkerHandle lifecycle (released once; invalid only on an
  unsalvageable TransportFailed).
- Add regression tests for the four correctness fixes above.
The comment claimed settledTermination returns a best-effort empty
Cancelled for the failure terminals, which was the pre-fix behavior.
close() now returns the settled terminal as-is: Failed / TransportFailed
carry their cause rather than collapsing to a bare Cancelled (asserted by
the pre-init-error and close-timeout tests). Also complete the verbless
"an init error from init()" fragment.

@Yicong-Huang Yicong-Huang left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks, the new code makes the state transitions much easier to follow. I have two high-level comments:

  1. The init-handshake logic still feels too complex. The complexity seems to come from using the same responseObserver for both init and the data phase, which spreads initResolved checks and init-waiter signaling across many branches. Would it be cleaner to isolate init as a one-shot completion and switch to data-phase handling only after it resolves?

  2. For the missing post-init-error Cancel, I think Initializing should transition through Cancelling rather than directly to Failed. sendCancelInternal already supports Initializing -> Cancelling, and the timeout and interrupt paths use it. The init-error path should similarly send Cancel first, then call Transitions.failed, instead of settling Failed early and suppressing the wire-level Cancel.

Please also see inline some detailed comments.

// 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)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Returns a fresh Cancelled even when a terminal already settled (e.g. the channel-shutdown TransportFailed in doInit also leaves requestObserver null), so the return disagrees with the state. Return settledTermination instead. Harmless today since base close() discards this value, but misleading.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

L529 only settles cancelled if (!currentState.isTerminal), then return settledTermination (L531).

Comment on lines +363 to +365
/** Failure terminal carrying a transport-level cause. */
def transportFailed(cause: Throwable): Boolean =
completeTerminal(Termination.TransportFailed(cause))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can this be merged to failed?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Comment on lines +417 to +424
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")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

who can interrupt this thread? if it is user, then this should be cancel? if this is the engine/system, then this should be failed?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@Yicong-Huang In spark's case, this usually means query cancellation, that can originate from a end-user clicking cancel or interrupt the query, or AQE/Spark engine cancel a stage.

I'm trying to introduce a better and consistent way of handling interrupts here.

On the other hand, I would propose us to move faster with this first version, as we can evolve over time later.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

it's a first-class Interrupted outcome that cooperatively cancels (bounded), keeps the worker reusable, and no longer masquerades as TransportFailed

The previous init-error handling settled a Failed terminal in the gRPC
response callback before attempting Cancel, so close() would report the
init error. But that made the wire Cancel unreachable: sendCancelInternal
bailed on both the directExecutor path (requestObserver still null) and
the cross-thread path (state already terminal). udf_message.proto requires
the engine to send Cancel after an init error and the worker to reply with
CancelResponse, so this was a protocol violation.

Root cause: the INIT-error / pre-init ERROR handling ran in the response
callback (handleControl), which under directExecutor reentrancy executes
inside doInit's stream.onNext -- before requestObserver is published on the
next line -- so a Cancel written there could hit a null observer.

Move the Cancel into doInit, which runs only after requestObserver is
published. The callbacks now just record the error and wake init, leaving
the session in Initializing; doInit's new failInitWithError helper sends
Cancel, drains the CancelResponse (a real awaitTerminal, no longer a no-op),
and throws. The reentrancy hazard is gone.

As a result the settled terminal on an init error is the proto terminator
Cancelled, not Failed -- the structured ExecutionError is surfaced through
init()'s exception and kept on executionError for the iterator / close()'s
salvage decision. Transitions.failed no longer has any producer and is
removed.

Also:
- doClose returns settledTermination when requestObserver is null rather
  than a fresh Cancelled that could disagree with an already-settled
  TransportFailed.
- OneShotValue.await throws TimeoutException instead of returning a Boolean
  the caller must inspect; the doInit call site handles it alongside the
  interrupt path.
- Expand the init-interrupt comment: an interrupt leaves the worker in an
  unknown state after a best-effort Cancel, so TransportFailed (not cancel)
  is the correct, unsalvageable outcome.

Update the two tests that asserted close() == Failed to the new contract:
init throws the structured error and close() returns Cancelled. The
cross-thread test now also asserts a Cancel was actually written to the
wire, pinning the protocol requirement.
onTerminalSettled now signals initValue, so every terminal-settling path
wakes a thread still blocked in doInit -- including close(), which settles
a terminal (cancelled when requestObserver is null, or transportFailed on
terminal timeout) without going through the response callback that would
otherwise signal initValue. Previously a close() concurrent with a blocked
init() left init() parked for the full initResponseTimeoutMs after the
terminal had already settled, contrary to the initValue latch invariant.

Because onTerminalSettled runs once, on the completeTerminal winner, after
the terminal CAS, settle-before-release still holds; the per-callback
terminal signalWithoutValue() calls become redundant but stay (idempotent),
while the non-terminal init signals remain required.

Tests: add a close-concurrent-with-blocked-init regression (terminal settled
by close() itself), plus coverage for the terminator-carried callback-error
paths (FinishResponse.error surfaced; data-phase ErrorResponse taking
precedence over a CancelResponse callback error) and the data-phase output
timeout. Full suite: 23 tests, 0 failures.
Doc-only consistency pass over the udf/worker module; no behavior change.

- Termination: the scaladoc said it "mirrors the four terminal
  SessionStates", contradicting WorkerSession, which states the four
  outcomes live once on Termination and the state has a single Terminal
  case. Reworded to describe the single-Terminal design.
- WorkerDispatcher: invalidation is driven by WorkerSession.close (via
  isWorkerSalvageable), not by doProcess as the doc claimed; doProcess
  never touches markInvalid. Corrected the described mechanism.
- UDFDispatcherManager: "read lock (with upgrade)" is a misnomer --
  ReentrantReadWriteLock cannot upgrade a held read lock, and the code
  releases the read lock before acquiring the write lock. Reworded.
- GrpcWorkerSession: note that the header wire diagram omits PayloadChunk*
  (chunking is not yet implemented; see the TODO) so it doesn't overstate
  a match with udf_message.proto.
- GrpcWorkerSessionConcurrencySuite: drop a stale reference to a "Closed"
  state (no such SessionState exists; close() settles a terminal).
An engine-side interrupt (cancelled query / killed task) is not a worker
fault, so it should not be reported as TransportFailed nor make the worker
unsalvageable. Introduce a distinct Termination.Interrupted and handle
interrupts cooperatively at every blocking point.

core:
- Termination.Interrupted(cause): a new terminal, documented as an
  engine-side interrupt distinct from a cooperative Cancelled (which drains
  a CancelResponse) and a TransportFailed (a genuine transport fault).
- isWorkerSalvageable leaves Interrupted salvageable (only TransportFailed
  and a never-settled session stay unsalvageable). Docs updated across
  Termination / WorkerSession / SessionState to enumerate five terminals
  and to stop attributing "interrupt" to TransportFailed.

grpc (GrpcWorkerSession):
- New handleInterrupt(waitContext) shared by every site that blocks on a
  worker event -- doInit's InitResponse wait, awaitTerminal, doClose's
  terminator wait, and ProcessIterator's output poll. It sends a
  best-effort Cancel, then waits up to interruptCancelTimeoutMs (new
  constructor param, default DEFAULT_INTERRUPT_CANCEL_TIMEOUT_MS = 2000ms,
  configurable) for the CancelResponse: a responsive worker settles a clean
  Cancelled (salvageable, response drained); on timeout it falls back to
  Interrupted (also salvageable). The bounded drain runs before the thread
  interrupt flag is restored, since InterruptedException clears it on throw.
- Explicit Interrupted arms added to doInit's terminal inspection and
  throwIfTerminalError so an interrupt surfaces a clear message.

TODO [SPARK-57640]: back the optimistic salvageable assumption with a
worker liveness/heartbeat check so an interrupt that leaves a wedged (but
not transport-dead) worker is still caught rather than recycled.

Tests: core gains an Interrupted salvageable-mapping case; grpc adds
responsive (-> Cancelled) and unresponsive (-> Interrupted, bounded by
interruptCancelTimeoutMs) interrupt-during-init tests. Suites:
WorkerSessionSuite 18, GrpcWorkerSessionConcurrencySuite 25,
EchoProtocolSuite 18 -- all pass.
Since onTerminalSettled now signals initValue (settling any terminal wakes
a blocked doInit), the explicit initValue.signalWithoutValue() calls in the
branches that also settle a terminal were redundant. Drop the 7 such calls
(responseObserver's DATA-before-init / malformed / onError / onCompleted,
and handleControl's FINISH / CANCEL / CONTROL_NOT_SET): those branches now
just settle the terminal, matching how the data phase already behaves, and
onTerminalSettled is the single place a settled terminal wakes doInit.

The only direct initValue signals left are the three genuinely non-terminal
init paths (handleControl's INIT-ok / INIT-error / pre-init-ERROR), which
must wake doInit without settling a terminal so the session stays
Initializing and process() can still run. This addresses review feedback
that init-waiter signaling was spread across too many branches.

No behavior change: GrpcWorkerSessionConcurrencySuite (25) and
EchoProtocolSuite (18) pass unchanged.
- Termination.Interrupted / Transitions.interrupted: the docs still said the
  post-interrupt Cancel's CancelResponse "is not awaited", which predates the
  bounded-drain design. It IS awaited briefly; Interrupted settles only when
  that short wait expires without an ack (otherwise a cooperative Cancelled
  settles). Correct both docs to match handleInterrupt.
- doClose: add a TODO noting that an interrupted close() blocks for another
  interruptCancelTimeoutMs to chase a clean, salvageable Cancelled, with the
  current tradeoff (keep the worker recyclable) and the revisit condition
  (killed-task teardown latency).
- awaitTerminal: drop a spurious s"" interpolator on a literal with no
  substitution.

Doc/comment only; no behavior change.
@haiyangsun-db

Copy link
Copy Markdown
Contributor Author

@Yicong-Huang

Thanks for the thorough review -- this was really helpful. I've pushed a series of commits addressing everything; recap below, with the design discussion on high-level point 1 at the end.

High-level point 2 (Cancel-after-init-error should go through Cancelling, not settle Failed early): done. The init-error path no longer settles Failed in the response callback; it records the error and leaves the session Initializing, and doInit (where requestObserver is guaranteed published) sends the Cancel and drains the CancelResponse, so the terminal settles Cancelled. Transitions.failed was removed entirely.

Inline comments: all addressed in the threads (recap so it's in one place):

  • INIT-error no longer settles a terminal early -> the wire Cancel is now actually written and its CancelResponse drained.
  • awaitTerminal() genuinely blocks for the CancelResponse again (it was a no-op only because of the early-settle bug above).
  • doClose's no-stream path only settles Cancelled when not already terminal, then returns settledTermination (no more fresh Cancelled disagreeing with a settled state).
  • OneShotValue.await now returns Unit and throws TimeoutException instead of returning a boolean.
  • Interrupt handling is now a first-class Interrupted termination (see that thread): a best-effort Cancel with a short bounded wait for the ack -- a clean, salvageable Cancelled if the worker responds in time, otherwise Interrupted (also salvageable, since the interrupt is an engine-side event, not a worker fault).

High-level point 1 (init-handshake complexity):

You're right that the signaling was the main source of noise, and it turned out to be mostly accidental. onTerminalSettled is now the single place a settled terminal wakes a blocked init(), so every branch that settles a terminal (the responseObserver failure/terminator cases, the timeout paths, close()) no longer signals the init waiter itself -- it just settles. That removed 7 redundant signalWithoutValue() calls; the only direct init signals left are the three genuinely non-terminal paths (INIT-ok, INIT-error, pre-init ERROR), which must wake init() without settling a terminal so the session stays Initializing and process() can still run. That's the irreducible core of the handshake.

On fully isolating init behind a one-shot handler and switching to a data-phase handler once it resolves: I looked at this and decided against it. gRPC gives us a single StreamObserver for the stream's whole life, so "switching" means either a swappable @volatile var handler or an if (initResolved) ... at the top of onNext. The former adds a mutable concurrency surface and a swap-vs-reentrant-delivery race -- exactly the trickiest part of this class -- and the latter just relocates the two initResolved checks that already exist. Neither removes the essential constraint (reentrant directExecutor delivery forcing the deferred Cancel), so I don't think the extra indirection pays for itself. Happy to revisit if you feel strongly.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants