spark-test commented on code in PR #60115:
URL: https://github.com/apache/spark/pull/60115#discussion_r4476879938


##########
python/pyspark/worker_util.py:
##########
@@ -265,6 +265,95 @@ def send_accumulator_updates(outfile: IO) -> None:
         pickleSer._write_with_length((aid, accum._value), outfile)
 
 
+class _EndOfInput:
+    """Last item ``read_in_thread`` queues, carrying the exception that ended 
the source."""
+
+    __slots__ = ("error",)
+
+    def __init__(self, error: Optional[Exception]) -> None:
+        self.error = error
+
+
+@contextmanager
+def read_in_thread(
+    iterator: Iterable[Any], queue_depth: int
+) -> Generator[Iterator[Any], None, None]:
+    """
+    Read ``iterator`` in a background thread, keeping up to ``queue_depth`` 
items ahead of the
+    caller, and yield an iterator over the same items.
+
+    This lets reading and deserializing the input overlap with the caller's 
work. Items that are
+    themselves iterators (e.g. lazy per-group iterators that still read the 
input stream) are
+    materialized into lists on the reader thread, so the caller never reads 
the stream. An
+    exception raised by ``iterator`` is re-raised to the caller once the items 
before it are
+    consumed.
+
+    On exit the reader is stopped and joined for up to 5 seconds. The input is 
never closed, as
+    the stream it reads may be reused by the next task of a reused worker; if 
the reader is still
+    blocked reading it, a RuntimeWarning is emitted instead.
+    """
+    import queue
+    import threading
+
+    item_queue: queue.Queue = queue.Queue(maxsize=queue_depth)
+    stop_event = threading.Event()
+
+    def _put(item: Any) -> bool:
+        # Block until there is room, giving up once the consumer has stopped.
+        while not stop_event.is_set():
+            try:
+                item_queue.put(item, timeout=0.1)
+                return True
+            except queue.Full:
+                continue
+        return False
+
+    def _read() -> None:
+        error = None
+        try:
+            for item in iterator:
+                if hasattr(item, "__next__"):
+                    item = list(item)
+                if not _put(item):

Review Comment:
   **Non-blocking (P2):** When the consumer leaves the `read_in_thread` context 
early, the exit sets `stop_event` and drains the queue, which lets a reader 
that is waiting in `_put` complete its put. `_put` then returns `True`, so this 
loop calls `next(iterator)` again and reads one more item from the worker input 
stream before the next `_put` sees the stop. The reader removed from 
`worker.py` re-checked `stop_event.is_set()` after every successful put and 
returned, so it never read after the stop.
   
   This affects only pipelined mode 
(`spark.python.udf.pipelined.enabled=true`), when the consumer exits before the 
end of input while the reader is waiting on a full queue, which is the usual 
state for a UDF that is slower than its input. Examples are a UDF or 
verification error, or a `mapInPandas`/`mapInArrow` function that returns 
early. Each such exit now reads and deserializes an extra Arrow batch, or 
materializes a whole extra group, that nothing uses; for pickled-row UDFs the 
extra item is a row, which reads the stream only when it starts a new pickled 
batch. If that item is slow to arrive, the exit waits for it, up to the 5s 
join, before the error is reported or the task finishes. Past 5s it also emits 
the "did not exit within 5s" `RuntimeWarning` and leaves the reader blocked on 
the same socket that `invoke_udf` then reads the finish signal from.
   
   A JVM-free reproduction that runs the base-branch reader and this one 
against a stub source reads the source 3 times on the base branch and 4 times 
here at queue depth 1 (one extra read at every depth tried). With the next item 
8s away, the exit takes 0.05s on the base branch and 5.05s plus the warning 
here. The new `test_early_exit_stops_reader_blocked_on_full_queue` allows the 
extra read (`assertLessEqual(len(produced), 4)`).
   
   Re-checking the stop after a successful put restores the base-branch 
behavior:
   
   ```suggestion
                   if not _put(item) or stop_event.is_set():
   ```
   
   **Verification:**
   - **Regression:** When the consumer leaves read_in_thread while the reader 
waits to enqueue on a full queue, the source is not read after the stop, at any 
queue depth and also when the items are lazy group iterators the reader 
materializes. For example, at queue depth 1 a consumer that takes one item and 
then leaves causes three source reads in total, the base-branch count, rather 
than four.
   - **Regression:** When the source's next read after the stop would block for 
longer than the 5 second join, leaving the context early returns promptly, 
emits no RuntimeWarning, and leaves no reader thread running.
   - **Behavior:** A source exception is still re-raised to the consumer after 
the items that preceded it, and a reader that is already blocked inside a read 
when the consumer stops still triggers the bounded join and the RuntimeWarning.



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