[ 
https://issues.apache.org/jira/browse/SPARK-57931?page=com.atlassian.jira.plugin.system.issuetabpanels:all-tabpanel
 ]

L. C. Hsieh updated SPARK-57931:
--------------------------------
    Description: 
SPARK-56642 added an opt-in pipelined Python UDF path. When enabled, 
createPipelinedDataIn() switches the shared worker's SocketChannel from 
non-blocking to blocking mode (channel.configureBlocking(true) + 
worker.refresh()) so the writer thread and the task thread can do full-duplex 
blocking I/O. The channel is never restored, so with worker reuse enabled 
(spark.python.worker.reuse=true, the default) the worker is returned to the 
idle pool with its channel still in blocking mode.

PythonWorker.refresh() only opens a selector when the channel is non-blocking. 
A pooled worker left in blocking mode therefore comes back with a null selector 
/ selectionKey, and selector-path (non-pipelined) code that dereferences 
worker.selector / worker.selectionKey would hit a NullPointerException.

In current OSS this is not an end-to-end failure: the worker-factory cache key 
(PythonWorkersKey) includes the worker envVars, and the pipelined path adds 
SPARK_PIPELINED_UDF=1 to envVars before requesting a worker. Pipelined and 
non-pipelined tasks therefore draw from separate idle pools -- a worker left in 
blocking mode only returns to the pipelined pool, whose next borrower is again 
a pipelined task that re-sets the channel to blocking and does not use the 
selector. So OSS masks the broken invariant via pool isolation.

That masking is fragile: it relies on the two pools staying disjoint via 
envVars and does not fix the underlying invariant that a pooled daemon worker 
is non-blocking. Any worker-management layer that pools or reuses workers 
across that boundary will hand a blocking-mode worker to selector-path code and 
hit the NPE.

Fix: normalize a reused daemon worker's channel back to non-blocking in 
PythonWorkerFactory.create() (the single pool exit point), so a pooled worker 
is always handed out in the same non-blocking mode as a fresh one. This is done 
in create() rather than the pipelined path's task-completion listener because 
the worker is released back to the pool when the reader reaches END_OF_STREAM, 
which runs before the completion listener.


  was:
SPARK-56642 added an opt-in pipelined Python UDF path. When enabled,
createPipelinedDataIn() switches the shared worker's SocketChannel from
non-blocking to blocking mode (channel.configureBlocking(true) + 
worker.refresh())
so the writer thread and the task thread can do full-duplex blocking I/O.

However, the channel mode is never restored. With worker reuse enabled
(spark.python.worker.reuse=true, the default), the task completion listener only
cancels the writer future; the worker is then released back into the idle pool
(PythonWorkerFactory.releaseWorker -> idleWorkers.enqueue) with its channel 
still
in blocking mode.

When a subsequent task pulls that worker from the pool, 
PythonWorkerFactory.create()
only calls worker.refresh(). Because refresh() decides whether to open a 
selector
based on the current channel mode, a channel left in blocking mode results in
selectorOpt = None / selectionKeyOpt = None. If that next task runs on the
non-pipelined (single-threaded NIO selector) path, it dereferences 
worker.selector
/ worker.selectionKey and hits a NullPointerException.

This is cross-task state pollution: a pooled worker borrowed once by the 
pipelined
path corrupts the channel state for every task that later reuses it, regardless 
of
whether that task uses pipelined mode. It also breaks setups that rely on pooled
workers being in a known (non-blocking) state.

Fix: capture the channel's original blocking mode before createPipelinedDataIn()
changes it, and restore it in the task completion listener (after the writer has
exited and only if the channel is still open), so a pooled worker is always
returned in the exact mode it was borrowed in.



> Restore worker channel blocking mode after pipelined Python UDF execution
> -------------------------------------------------------------------------
>
>                 Key: SPARK-57931
>                 URL: https://issues.apache.org/jira/browse/SPARK-57931
>             Project: Spark
>          Issue Type: Bug
>          Components: SQL
>    Affects Versions: 4.3.0
>            Reporter: L. C. Hsieh
>            Priority: Major
>              Labels: pull-request-available
>
> SPARK-56642 added an opt-in pipelined Python UDF path. When enabled, 
> createPipelinedDataIn() switches the shared worker's SocketChannel from 
> non-blocking to blocking mode (channel.configureBlocking(true) + 
> worker.refresh()) so the writer thread and the task thread can do full-duplex 
> blocking I/O. The channel is never restored, so with worker reuse enabled 
> (spark.python.worker.reuse=true, the default) the worker is returned to the 
> idle pool with its channel still in blocking mode.
> PythonWorker.refresh() only opens a selector when the channel is 
> non-blocking. A pooled worker left in blocking mode therefore comes back with 
> a null selector / selectionKey, and selector-path (non-pipelined) code that 
> dereferences worker.selector / worker.selectionKey would hit a 
> NullPointerException.
> In current OSS this is not an end-to-end failure: the worker-factory cache 
> key (PythonWorkersKey) includes the worker envVars, and the pipelined path 
> adds SPARK_PIPELINED_UDF=1 to envVars before requesting a worker. Pipelined 
> and non-pipelined tasks therefore draw from separate idle pools -- a worker 
> left in blocking mode only returns to the pipelined pool, whose next borrower 
> is again a pipelined task that re-sets the channel to blocking and does not 
> use the selector. So OSS masks the broken invariant via pool isolation.
> That masking is fragile: it relies on the two pools staying disjoint via 
> envVars and does not fix the underlying invariant that a pooled daemon worker 
> is non-blocking. Any worker-management layer that pools or reuses workers 
> across that boundary will hand a blocking-mode worker to selector-path code 
> and hit the NPE.
> Fix: normalize a reused daemon worker's channel back to non-blocking in 
> PythonWorkerFactory.create() (the single pool exit point), so a pooled worker 
> is always handed out in the same non-blocking mode as a fresh one. This is 
> done in create() rather than the pipelined path's task-completion listener 
> because the worker is released back to the pool when the reader reaches 
> END_OF_STREAM, which runs before the completion listener.



--
This message was sent by Atlassian Jira
(v8.20.10#820010)

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

Reply via email to