Skip to content

[SPARK-56413][SQL][UDF] gRPC implementation of the UDF worker protocol dispatcher - #55683

Open
haiyangsun-db wants to merge 3 commits into
apache:masterfrom
haiyangsun-db:SPARK-56413-grpc-impl
Open

[SPARK-56413][SQL][UDF] gRPC implementation of the UDF worker protocol dispatcher#55683
haiyangsun-db wants to merge 3 commits into
apache:masterfrom
haiyangsun-db:SPARK-56413-grpc-impl

Conversation

@haiyangsun-db

@haiyangsun-db haiyangsun-db commented May 5, 2026

Copy link
Copy Markdown
Contributor

What changes were proposed in this pull request?

Adds the first transport implementation of the UDF worker protocol: gRPC bidirectional streaming over a Unix domain socket, as a new udf-worker-grpc module.

This PR builds on the engine-side core abstractions and the proto split introduced in the base PR#56397 (which is the base of this branch); those are not part of this diff. For context, the base PR provides:

  • the proto split into udf_message.proto (message types) and
    udf_service.proto (the UdfWorker gRPC service), keeping gRPC off the
    classpath of the message consumers (core, catalyst, sql-core);
  • the WorkerSession SessionState state machine, the Termination type,
    and the WorkerHandle trait.

This PR is scoped to the gRPC implementation on top of those abstractions:

New udf-worker-grpc module:

  • DirectGrpcDispatcher -- a DirectWorkerDispatcher that wires UDS gRPC
    into the existing worker spawn / lifecycle machinery (private 0700 socket
    dir, atomic POSIX permissions with an owner-only non-POSIX fallback,
    recursive cleanup, UDS path-length-safe socket naming).
    validateTransportSupport requires the spec to declare UDS transport and
    BIDIRECTIONAL_STREAMING.
  • GrpcWorkerSession -- the engine-side WorkerSession driving one
    bidirectional Execute stream per UDF execution. Honors the proto ordering
    invariants (Init -> DataRequest* -> Finish, Cancel-not-before-Finish on the
    engine side; InitResponse -> DataResponse* -> terminator on the worker
    side), serializes wire writes under a single requestLock, surfaces
    transport / execution / protocol failures as GrpcWorkerSessionException,
    and bounds every wait by an explicit timeout. It keeps no parallel state
    machine -- it drives the single SessionState owned by the base
    WorkerSession, advancing only the protocol-event edges.
  • GrpcWorkerChannel -- per-worker ManagedChannel plus a dedicated Netty
    EventLoopGroup over the UDS, with deterministic close ordering.
  • UnixDomainSocketTransport -- runtime detection of Linux epoll /
    macOS kqueue native UDS transports, with a loud failure when neither is
    available.

Integration: sql/core gains a test-scope dependency on the grpc test-jar so
PythonUDFWorkerSpecificationSuite can reuse the grpc test dispatcher; gRPC
stays off sql/core's main classpath. The module is @Experimental and has
no production callers yet.

Why are the changes needed?

The UDF worker protocol defines a language-agnostic execution surface but did
not yet have an implementation. This PR delivers gRPC-over-UDS as the first
transport so the dispatcher can run UDFs end to end. Native UDS via Netty
avoids TCP overhead on the local socket; gRPC bidirectional streaming gives us
ordered delivery, per-message size limits, and well-tested flow control.

Does this PR introduce any user-facing change?

No. udf/worker is @Experimental and not yet wired into any Spark execution
path.

How was this patch tested?

  • New GrpcWorkerSessionConcurrencySuite (in-process tests) pins the
    wire-ordering and fast-fail invariants: a worker-emitted ERROR / FINISH /
    CANCEL / onCompleted() before InitResponse all fail init() fast;
    repeated hasNext after exhaustion returns immediately; close() concurrent
    with process() terminates cleanly; and the same fast-fail / data-phase
    paths are re-run under cross-thread (non-directExecutor) delivery so
    correctness does not silently depend on reentrant delivery.
  • New DirectGrpcDispatcherIntegrationSuite (subprocess tests via
    EchoGrpcWorkerMain): end-to-end echo, sequential and concurrent sessions,
    worker-crash mid-process, init failure, capability-mismatch rejection
    (including a spec with no declared capabilities), socket-directory cleanup,
    reuse-readiness.
  • Existing EchoProtocolSuite refactored onto a shared EchoWorkerService and
    continues to cover the wire protocol end to end.
  • DirectWorkerDispatcherSuite ported to the gRPC test dispatcher: spawn /
    SIGTERM / SIGKILL escalation / environment lifecycle / concurrent close /
    timeout clamping.
  • All suites green; sql/Test/compile clean.

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

Yes

@haiyangsun-db
haiyangsun-db force-pushed the SPARK-56413-grpc-impl branch 2 times, most recently from 93ec09e to eef803e Compare May 6, 2026 04:03
@haiyangsun-db haiyangsun-db changed the title [Spark-56413] Implement bidirectional streaming for grpc protocol. [SPARK-56413][CORE] Add gRPC bi-directional streaming UDF worker protocol May 6, 2026
@haiyangsun-db haiyangsun-db changed the title [SPARK-56413][CORE] Add gRPC bi-directional streaming UDF worker protocol [SPARK-56413][UDF] Add gRPC bi-directional streaming UDF worker protocol May 6, 2026
@haiyangsun-db
haiyangsun-db force-pushed the SPARK-56413-grpc-impl branch from eef803e to 8d45dfe Compare May 6, 2026 04:57
@haiyangsun-db
haiyangsun-db force-pushed the SPARK-56413-grpc-impl branch 2 times, most recently from e45886e to 9ada1b2 Compare May 27, 2026 21:13
@haiyangsun-db
haiyangsun-db marked this pull request as ready for review May 28, 2026 08:18
@haiyangsun-db
haiyangsun-db marked this pull request as draft May 28, 2026 09:47
@haiyangsun-db haiyangsun-db changed the title [SPARK-56413][UDF] Add gRPC bi-directional streaming UDF worker protocol [SPARK-56413][SQL][UDF] gRPC implementation of the UDF worker protocol dispatcher May 28, 2026
@haiyangsun-db
haiyangsun-db marked this pull request as ready for review May 28, 2026 11:13
* - [[cancel]] is thread-safe and idempotent.
* - [[doClose]] half-closes the request stream after the terminator arrives.
*
* NOTE: this class does not yet implement payload chunking; the entire

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.

nit: Add a TODO with the JIRA tracking ticket for this

@haiyangsun-db

Copy link
Copy Markdown
Contributor Author

Hi @dtenedor @hvanhovell @Yicong-Huang , please feel free to review this changes. It's the Dispatcher/Session implementation of the gRPC protocol following last PR.

Spark can then use the Dispatcher and session interface to talk to udf worker, which encapsulates the protocol details or worker management.

def detect(): UnixDomainSocketTransport = {
val os = System.getProperty("os.name", "").toLowerCase(Locale.ROOT)
if (isLinux(os) && epollAvailable) {
EpollTransport

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.

I think Netty is also adding support iouring.

throw new UnsupportedOperationException(
s"No Netty native UDS transport available on os=$os " +
s"(epoll: $epollErr, kqueue: $kqueueErr). " +
"UDS-backed gRPC requires netty-transport-native-epoll on Linux or " +

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.

Is there a socket based fallback?

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.

could be, but out-of-scoped this PR

message: String,
cause: Throwable = null,
val executionError: ExecutionError = null)
extends RuntimeException(message, 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.

Should this be an SparkRuntimeException? I understand that this requires an additional dependency.

@haiyangsun-db haiyangsun-db Jun 4, 2026

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.

Spark side should wrap exception thrown in this module into SparkRuntimeException, let me update the doc about that.
The intention is to make this module as isolated from spark as possible.


/**
* :: Experimental ::
* Exception thrown by [[GrpcWorkerSession]] when the UDF execution fails

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.

Is this a mix of system and user errors? If we can, it would be preferable to separate these.

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.

We shall have three layers of errors:

  1. error from udf
  2. error from worker / protocol (still logic at worker level)
  3. transport error (grpc, etc)

I was trying to merge 1/2 into one big category as these are programmable errors usually and use subclasses to tell udf-only error from worker/protocol error.

val DEFAULT_TERMINAL_TIMEOUT_MS: Long = 30000L

// Items the gRPC response observer pushes onto outputQueue. Using a sealed
// ADT (rather than an identity-typed `Array[Byte]` sentinel) means an

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.

A bit of a pedantic comment. Yes, ADTs are easier to reason about that naked byte arrays.

val u = err.getUser
val cls = if (u.hasErrorClass) s"[${u.getErrorClass}] " else ""
s"$cls${u.getMessage}"
case ExecutionError.KindCase.WORKER =>

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 a worker error be caused by the user?

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 possible, that's why i tend to combine worker error with user error, and distinguish using subclasses => they are definitely different from transport-level errors.

terminalTimeoutMs: Long = DEFAULT_TERMINAL_TIMEOUT_MS)
extends WorkerSession(workerHandle, logger) {

require(channel != null, "channel is required")

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.

Not checking the handle?

// We deliberately do NOT bound this queue: blocking the gRPC receiving
// thread is worse than the current unbounded-but-fast behaviour. A proto
// change is out of scope here.
private val outputQueue = new LinkedBlockingQueue[QueueItem]()

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.

We should have metrics for this.

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

@hvanhovell hvanhovell Jun 2, 2026

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.

A Phaser might be more appropriate. You can combine this with other latch.


// (2) Send next input batch when input has data and session is live.
if (!cancelRequested.get() && !finishedSending.get() && input.hasNext) {
val batch = try input.next() catch {

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.

This sends a singular batch per iteration. Shouldn't we try to fill the send queue to a certain level?

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 while loop, it will keep sending input until seeing a response.

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.

we prefer consuming results over sending to avoid results filling up buffer

// (4) Block for late output or the terminator. See class doc
// above for the per-poll-vs-total-session timeout semantics.
val item = try {
outputQueue.poll(terminalTimeoutMs, TimeUnit.MILLISECONDS)

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.

How does this work well with things like mapPartitions? In theory you can have a mapPartitions function that needs to consume all data first (e.g. home grown aggregation). I think that will time out if the data is partitioned into multi batches.

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.

This is a loop, and this poll with timeout only happens when all input batches have been sent in this class.
If input are not all sent, we use poll without timeout (non-blocking) to check if there is any response.

We know at this moment, receiving responses is the only thing to do.

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.

when we hit this branch, it means we sent all input and finish messages, and the only thing left is to wait for result response.

@haiyangsun-db
haiyangsun-db force-pushed the SPARK-56413-grpc-impl branch from d749c06 to c50e125 Compare June 8, 2026 08:32
@haiyangsun-db
haiyangsun-db marked this pull request as draft June 8, 2026 13:46
// runs on the WorkerSession.close() path and an exception here would
// mask any in-flight failure the caller is already handling.
val msg = s"releaseSession called without a matching acquireSession (count=$c)"
assert(c >= 0, msg)

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.

IMO this should be a harder error than an assertion error.

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.

fixed.

@haiyangsun-db
haiyangsun-db marked this pull request as ready for review June 8, 2026 21:38
@haiyangsun-db

Copy link
Copy Markdown
Contributor Author

Split the WorkerSession state-machine change into a separate PR for easy review: https://github.com/apache/spark/pull/56397/changes

@haiyangsun-db
haiyangsun-db force-pushed the SPARK-56413-grpc-impl branch from ca42ad4 to 1ffaa80 Compare June 10, 2026 14:16

@uros-b uros-b left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

@haiyangsun-db FYI: please coordinate with #56741, because of possible merge order overlap.

@haiyangsun-db

Copy link
Copy Markdown
Contributor Author

@uros-b thank you for the reminder, i will update

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.

4 participants