viirya commented on code in PR #7181:
URL: https://github.com/apache/datafusion-comet/pull/7181#discussion_r4369216134
##########
spark/src/main/scala/org/apache/comet/CometExecIterator.scala:
##########
@@ -235,6 +235,11 @@ class CometExecIterator(
CometExecIterator.startMemoryUsageLog()
+ // A native Python UDF ignores the task's kill, so watch the task to
interrupt its calls.
+ if (PythonUdfMonitor.supported && nativeLib.hasPythonUdf(plan)) {
Review Comment:
Right: `addTaskCompletionListener` runs the listener at once on a completed
task, and `close()` releases the plan before the probe read it. Fixed in
3d9fe9ec9. The probe now runs inside the cleanup-protected block that creates
the plan, before the listener is registered. That block also binds the task's
token (see the token thread) and counts the plan for the monitor, so a failure
there releases the plan like any other setup failure.
##########
native/core/src/execution/python_udf.rs:
##########
@@ -29,14 +29,177 @@ use pyo3::ffi::Py_uintptr_t;
use pyo3::prelude::*;
use pyo3::sync::MutexExt;
use pyo3::types::{PyBytes, PyList, PyTuple};
-use std::sync::{Mutex, PoisonError};
+use pyo3::PyTypeInfo;
+use std::collections::HashMap;
+use std::os::raw::{c_long, c_ulong};
+use std::sync::{Arc, LazyLock, Mutex, MutexGuard, PoisonError};
// Tasks create their callables concurrently. Concurrent first imports of a
package
// can hand a thread a partially initialized module (observed with PyArrow on
Python
// 3.10), so callables are unpickled one at a time. Take it with
`lock_py_attached`,
// which waits for the lock without holding the GIL.
static IMPORT_LOCK: Mutex<()> = Mutex::new(());
+pyo3::create_exception!(
+ comet,
+ TaskKilled,
+ pyo3::exceptions::PyBaseException,
+ "Raised in a running Arrow UDF when Spark kills its task."
+);
+
+unsafe extern "C" {
+ // Part of the stable Python C API, but not declared by pyo3-ffi.
+ fn PyThread_get_thread_ident() -> c_ulong;
+}
+
+/// The native plans of one Spark task attempt that hold Arrow UDFs.
+#[derive(Default)]
+struct TaskCalls {
+ plans: usize,
+ killed: bool,
+ /// The calls running now.
+ calls: Vec<Call>,
+}
+
+struct Call {
+ /// Python thread id of the thread running the call.
+ thread: c_ulong,
+ /// Whether [`interrupt_task`] has raised in the call. It raises once, so
that a second
+ /// exception does not abort the cleanup the first one started.
+ interrupted: bool,
+}
+
+// Holders of this lock never wait for the GIL, so a thread attached to Python
may take it.
+static TASKS: LazyLock<Mutex<HashMap<i64, TaskCalls>>> =
LazyLock::new(Default::default);
+
+fn tasks() -> MutexGuard<'static, HashMap<i64, TaskCalls>> {
+ TASKS.lock().unwrap_or_else(PoisonError::into_inner)
+}
+
+/// Registers a native plan's Arrow UDFs under their Spark task attempt, so
that
+/// [`interrupt_task`] reaches their calls. The task's entry is removed when
its last
+/// registration is dropped, which happens when the plan is released.
+pub struct TaskRegistration {
+ task_attempt_id: i64,
+}
+
+impl TaskRegistration {
+ pub fn new(task_attempt_id: i64) -> Self {
+ tasks().entry(task_attempt_id).or_default().plans += 1;
+ Self { task_attempt_id }
+ }
+
+ fn is_killed(&self) -> bool {
+ tasks()
+ .get(&self.task_attempt_id)
+ .is_some_and(|task| task.killed)
+ }
+
+ fn killed_error(&self) -> ArrowError {
+ ArrowError::ComputeError(format!(
+ "Arrow UDF stopped because task {} was killed",
+ self.task_attempt_id
+ ))
+ }
+}
+
+impl Drop for TaskRegistration {
+ fn drop(&mut self) {
+ let mut tasks = tasks();
+ if let Some(task) = tasks.get_mut(&self.task_attempt_id) {
+ task.plans -= 1;
+ if task.plans == 0 {
+ tasks.remove(&self.task_attempt_id);
+ }
+ }
+ }
+}
+
+/// A call to the user's callable, recorded so that [`interrupt_task`] can
raise in its thread.
+struct RunningCall<'a> {
+ task: &'a TaskRegistration,
+ thread: c_ulong,
+}
+
+impl<'a> RunningCall<'a> {
+ /// Fails without recording the call when the task has already been killed.
+ fn enter(_py: Python<'_>, task: &'a TaskRegistration) -> Result<Self> {
+ // SAFETY: the caller is attached to the interpreter.
+ let thread = unsafe { PyThread_get_thread_ident() };
+ let mut tasks = tasks();
+ let calls = tasks.entry(task.task_attempt_id).or_default();
+ if calls.killed {
+ return Err(task.killed_error());
+ }
+ calls.calls.push(Call {
+ thread,
+ interrupted: false,
+ });
+ Ok(Self { task, thread })
+ }
+}
+
+impl Drop for RunningCall<'_> {
+ fn drop(&mut self) {
+ let mut tasks = tasks();
+ let Some(calls) = tasks.get_mut(&self.task.task_attempt_id) else {
+ return;
+ };
+ let Some(index) = calls.calls.iter().position(|c| c.thread ==
self.thread) else {
+ return;
+ };
+ if calls.calls.swap_remove(index).interrupted {
+ // An interrupt that arrived as the callable returned is still
pending on this
+ // thread. Nothing can set another one now that the call is
unrecorded, so clear
+ // it rather than let it fire in later Python code on this thread.
+ // SAFETY: the call is made while attached, on the thread it names.
+ unsafe {
+ pyo3::ffi::PyThreadState_SetAsyncExc(self.thread as c_long,
std::ptr::null_mut())
+ };
+ }
+ }
+}
+
+/// Marks a Spark task attempt as killed and raises [`TaskKilled`] in its
running Arrow UDF
+/// calls that it has not interrupted yet. Later calls of the task fail
without entering
+/// Python. Returns the number of calls interrupted. A task with no registered
plan is not
+/// recorded, so the caller repeats this until the task completes.
+///
+/// The exception takes effect when the thread next runs Python bytecode: a
call blocked in
+/// native code that released the GIL is interrupted only when that code
returns.
+pub fn interrupt_task(task_attempt_id: i64) -> usize {
+ let running = match tasks().get_mut(&task_attempt_id) {
+ Some(task) => {
+ task.killed = true;
+ task.calls.iter().any(|call| !call.interrupted)
+ }
+ None => false,
+ };
+ if !running {
+ return 0;
+ }
+ // Calls cannot start after the flag is set, so only calls recorded before
it remain.
+ Python::attach(|py| {
+ let exception = TaskKilled::type_object(py);
+ let mut tasks = tasks();
+ let Some(task) = tasks.get_mut(&task_attempt_id) else {
+ return 0;
+ };
+ let mut interrupted = 0;
+ for call in task.calls.iter_mut().filter(|call| !call.interrupted) {
+ // SAFETY: attached to the interpreter, and `exception` is a live
type object.
+ let set = unsafe {
+ pyo3::ffi::PyThreadState_SetAsyncExc(call.thread as c_long,
exception.as_ptr())
Review Comment:
This one holds and the code cannot rule it out: the exception can fire right
after a C `acquire()` returns and before the `try` whose `finally` releases it,
and a kill that lands while the call waits for the lock makes that the likely
outcome rather than a narrow race. A Python `__enter__` that acquires a lock
has the same gap. Moving `acquire()` into the `try` only trades it for
releasing a lock that was never taken. Spark avoids the problem by destroying
the worker process, which a shared interpreter cannot do.
In 3d9fe9ec9 the monitor reduces and exposes the risk instead:
- A killed task's later calls fail at once without entering Python, which is
always safe.
- A running call gets `TaskKilled` only after it has run for
`spark.python.task.killTimeout` since the kill, like Spark's monitor, so calls
that finish within the timeout are never interrupted.
- `spark.comet.exec.nativeArrowPythonUDF.interruptOnKill.enabled=false`
disables raising in running calls; a killed task then waits for its call to
return. `kill_without_interrupt_lets_the_running_call_finish` covers that mode.
- The user guide describes the lock hazard, including `logging` before
Python 3.13, next to the setting.
Explicit cooperative cancellation would not stop an arbitrary loop, which is
the case #6811 is about, so I kept the forced interrupt as the default with the
opt-out.
##########
spark/src/main/scala/org/apache/comet/PythonUdfMonitor.scala:
##########
@@ -0,0 +1,70 @@
+/*
+ * 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.comet
+
+import java.util.concurrent.ConcurrentHashMap
+
+import org.apache.spark.TaskContext
+import org.apache.spark.internal.Logging
+
+/**
+ * Interrupts the native Python UDF calls of killed tasks. Spark's Python
runner has a
+ * `MonitorThread` that destroys the worker of a killed task. A native UDF
runs in the embedded
+ * interpreter on the task thread instead, where neither a Java interrupt nor
a stopped input
+ * reaches it. So one daemon thread per task polls its kill state and, while
the task is killed
+ * but still running, raises an exception in the task's running Python calls,
once per call so
+ * that its cleanup can run. Calls that start after the first interrupt fail
without entering
+ * Python.
+ */
+object PythonUdfMonitor extends Logging {
+
+ // Raising the exception is cheap and lets the call unwind on its own, so
unlike Spark's
+ // monitor this one does not wait `spark.python.task.killTimeout` before
acting. Repeating it
+ // reaches calls of plans that the task registers after an earlier attempt.
+ private val PollIntervalMs = 100L
+
+ private val watchedTasks = ConcurrentHashMap.newKeySet[java.lang.Long]()
+
+ /** Whether the native library can run Python UDFs, and so may need its
calls interrupted. */
+ lazy val supported: Boolean = NativeBase.supportsPythonUdf()
+
+ /** Starts watching the task unless it is watched already. The watch ends
with the task. */
+ def watch(context: TaskContext): Unit = {
+ val taskAttemptId = context.taskAttemptId()
+ if (watchedTasks.add(taskAttemptId)) {
+ val thread = new Thread(s"Comet Python UDF monitor for task
$taskAttemptId") {
+ override def run(): Unit = {
+ try {
+ while (!context.isCompleted()) {
Review Comment:
Agreed. `BatchProducer.stop` takes the stream's mutex, which the producer
holds for the whole poll, including the Python call, so `releasePlan` waits for
that call after `isCompleted` is already true. Fixed in 3d9fe9ec9. Each watch
counts the native UDF plans bound to it, `CometExecIterator.close` uncounts its
plan after `releasePlan` returns, and the monitor runs until the task has
completed and no counted plan remains. The new "killing a completed task
interrupts the UDF call its plan release waits for" test reads a two-row
Parquet file one row per batch, takes the first row while the second loops in
the UDF, completes the task, and cancels the job; it fails with the previous
lifetime.
##########
native/core/src/execution/python_udf.rs:
##########
@@ -29,14 +29,177 @@ use pyo3::ffi::Py_uintptr_t;
use pyo3::prelude::*;
use pyo3::sync::MutexExt;
use pyo3::types::{PyBytes, PyList, PyTuple};
-use std::sync::{Mutex, PoisonError};
+use pyo3::PyTypeInfo;
+use std::collections::HashMap;
+use std::os::raw::{c_long, c_ulong};
+use std::sync::{Arc, LazyLock, Mutex, MutexGuard, PoisonError};
// Tasks create their callables concurrently. Concurrent first imports of a
package
// can hand a thread a partially initialized module (observed with PyArrow on
Python
// 3.10), so callables are unpickled one at a time. Take it with
`lock_py_attached`,
// which waits for the lock without holding the GIL.
static IMPORT_LOCK: Mutex<()> = Mutex::new(());
+pyo3::create_exception!(
+ comet,
+ TaskKilled,
+ pyo3::exceptions::PyBaseException,
+ "Raised in a running Arrow UDF when Spark kills its task."
+);
+
+unsafe extern "C" {
+ // Part of the stable Python C API, but not declared by pyo3-ffi.
+ fn PyThread_get_thread_ident() -> c_ulong;
+}
+
+/// The native plans of one Spark task attempt that hold Arrow UDFs.
+#[derive(Default)]
+struct TaskCalls {
+ plans: usize,
+ killed: bool,
+ /// The calls running now.
+ calls: Vec<Call>,
+}
+
+struct Call {
+ /// Python thread id of the thread running the call.
+ thread: c_ulong,
+ /// Whether [`interrupt_task`] has raised in the call. It raises once, so
that a second
+ /// exception does not abort the cleanup the first one started.
+ interrupted: bool,
+}
+
+// Holders of this lock never wait for the GIL, so a thread attached to Python
may take it.
+static TASKS: LazyLock<Mutex<HashMap<i64, TaskCalls>>> =
LazyLock::new(Default::default);
+
+fn tasks() -> MutexGuard<'static, HashMap<i64, TaskCalls>> {
+ TASKS.lock().unwrap_or_else(PoisonError::into_inner)
+}
+
+/// Registers a native plan's Arrow UDFs under their Spark task attempt, so
that
+/// [`interrupt_task`] reaches their calls. The task's entry is removed when
its last
+/// registration is dropped, which happens when the plan is released.
+pub struct TaskRegistration {
+ task_attempt_id: i64,
+}
+
+impl TaskRegistration {
+ pub fn new(task_attempt_id: i64) -> Self {
+ tasks().entry(task_attempt_id).or_default().plans += 1;
Review Comment:
Agreed, `taskAttemptId` is only unique within one SparkContext. Fixed in
3d9fe9ec9. `PythonUdfMonitor` assigns each watched `TaskContext` a token from a
process-wide counter. `CometExecIterator` binds it to the plan with a new
`Native.setPythonUdfTask` before the first `executePlan`, and the native
registry, `ArrowPythonUdfExec`, and `NativeBase.killPythonUdfs` all use the
token instead of the attempt id.
##########
native/core/src/execution/python_udf.rs:
##########
@@ -297,14 +468,18 @@ impl ArrowPythonUdf {
kwargs.set_item(name, arg).map_err(python_error)?;
}
}
- let result = self
- .callable
- .bind(py)
- .call(
- PyTuple::new(py, positional).map_err(python_error)?,
- Some(&kwargs),
- )
- .map_err(|error| python_traceback_error(py, error))?;
+ let positional = PyTuple::new(py,
positional).map_err(python_error)?;
+ let call = self
+ .task
+ .as_deref()
+ .map(|task| RunningCall::enter(py, task))
+ .transpose()?;
+ let result = self.callable.bind(py).call(positional,
Some(&kwargs));
+ drop(call);
Review Comment:
Fixed in 3d9fe9ec9. The call now stays recorded through the Spark 4.2
`RecordBatch.from_arrays` conversion, and the killed check runs after it, so a
cancelled conversion fails instead of returning a batch.
`interrupts_a_user_defined_array_conversion` interrupts a result whose
`__arrow_array__` loops, and fails if the call is unrecorded before the
conversion.
##########
native/core/src/execution/python_udf.rs:
##########
@@ -29,14 +29,177 @@ use pyo3::ffi::Py_uintptr_t;
use pyo3::prelude::*;
use pyo3::sync::MutexExt;
use pyo3::types::{PyBytes, PyList, PyTuple};
-use std::sync::{Mutex, PoisonError};
+use pyo3::PyTypeInfo;
+use std::collections::HashMap;
+use std::os::raw::{c_long, c_ulong};
+use std::sync::{Arc, LazyLock, Mutex, MutexGuard, PoisonError};
// Tasks create their callables concurrently. Concurrent first imports of a
package
// can hand a thread a partially initialized module (observed with PyArrow on
Python
// 3.10), so callables are unpickled one at a time. Take it with
`lock_py_attached`,
// which waits for the lock without holding the GIL.
static IMPORT_LOCK: Mutex<()> = Mutex::new(());
+pyo3::create_exception!(
+ comet,
+ TaskKilled,
+ pyo3::exceptions::PyBaseException,
+ "Raised in a running Arrow UDF when Spark kills its task."
+);
+
+unsafe extern "C" {
+ // Part of the stable Python C API, but not declared by pyo3-ffi.
+ fn PyThread_get_thread_ident() -> c_ulong;
+}
+
+/// The native plans of one Spark task attempt that hold Arrow UDFs.
+#[derive(Default)]
+struct TaskCalls {
+ plans: usize,
+ killed: bool,
+ /// The calls running now.
+ calls: Vec<Call>,
+}
+
+struct Call {
+ /// Python thread id of the thread running the call.
+ thread: c_ulong,
+ /// Whether [`interrupt_task`] has raised in the call. It raises once, so
that a second
+ /// exception does not abort the cleanup the first one started.
+ interrupted: bool,
+}
+
+// Holders of this lock never wait for the GIL, so a thread attached to Python
may take it.
+static TASKS: LazyLock<Mutex<HashMap<i64, TaskCalls>>> =
LazyLock::new(Default::default);
+
+fn tasks() -> MutexGuard<'static, HashMap<i64, TaskCalls>> {
+ TASKS.lock().unwrap_or_else(PoisonError::into_inner)
+}
+
+/// Registers a native plan's Arrow UDFs under their Spark task attempt, so
that
+/// [`interrupt_task`] reaches their calls. The task's entry is removed when
its last
+/// registration is dropped, which happens when the plan is released.
+pub struct TaskRegistration {
+ task_attempt_id: i64,
+}
+
+impl TaskRegistration {
+ pub fn new(task_attempt_id: i64) -> Self {
+ tasks().entry(task_attempt_id).or_default().plans += 1;
+ Self { task_attempt_id }
+ }
+
+ fn is_killed(&self) -> bool {
+ tasks()
+ .get(&self.task_attempt_id)
+ .is_some_and(|task| task.killed)
+ }
+
+ fn killed_error(&self) -> ArrowError {
+ ArrowError::ComputeError(format!(
+ "Arrow UDF stopped because task {} was killed",
+ self.task_attempt_id
+ ))
+ }
+}
+
+impl Drop for TaskRegistration {
+ fn drop(&mut self) {
+ let mut tasks = tasks();
+ if let Some(task) = tasks.get_mut(&self.task_attempt_id) {
+ task.plans -= 1;
+ if task.plans == 0 {
+ tasks.remove(&self.task_attempt_id);
+ }
+ }
+ }
+}
+
+/// A call to the user's callable, recorded so that [`interrupt_task`] can
raise in its thread.
+struct RunningCall<'a> {
+ task: &'a TaskRegistration,
+ thread: c_ulong,
+}
+
+impl<'a> RunningCall<'a> {
+ /// Fails without recording the call when the task has already been killed.
+ fn enter(_py: Python<'_>, task: &'a TaskRegistration) -> Result<Self> {
+ // SAFETY: the caller is attached to the interpreter.
+ let thread = unsafe { PyThread_get_thread_ident() };
+ let mut tasks = tasks();
+ let calls = tasks.entry(task.task_attempt_id).or_default();
+ if calls.killed {
+ return Err(task.killed_error());
+ }
+ calls.calls.push(Call {
+ thread,
+ interrupted: false,
+ });
+ Ok(Self { task, thread })
+ }
+}
+
+impl Drop for RunningCall<'_> {
+ fn drop(&mut self) {
+ let mut tasks = tasks();
+ let Some(calls) = tasks.get_mut(&self.task.task_attempt_id) else {
+ return;
+ };
+ let Some(index) = calls.calls.iter().position(|c| c.thread ==
self.thread) else {
+ return;
+ };
+ if calls.calls.swap_remove(index).interrupted {
+ // An interrupt that arrived as the callable returned is still
pending on this
+ // thread. Nothing can set another one now that the call is
unrecorded, so clear
+ // it rather than let it fire in later Python code on this thread.
+ // SAFETY: the call is made while attached, on the thread it names.
+ unsafe {
+ pyo3::ffi::PyThreadState_SetAsyncExc(self.thread as c_long,
std::ptr::null_mut())
Review Comment:
Agreed. `PyThreadState_SetAsyncExc` signals the interpreter even for a NULL
exception, and only a thread that holds an exception clears the flag. Fixed in
3d9fe9ec9. After unregistering an interrupted call and releasing the registry
lock, the thread runs a short loop of Python bytecode, so a still-pending
`TaskKilled` fires and is discarded there, and nothing is ever cleared with
NULL. `consumes_an_interrupt_pending_when_the_call_returns` interrupts a C
callable (`functools.partial(time.sleep, 0.3)`) that returns straight to Rust,
then runs more Python on the same thread state and checks that the interrupt
does not fire there.
##########
native/core/src/execution/python_udf.rs:
##########
@@ -297,14 +468,18 @@ impl ArrowPythonUdf {
kwargs.set_item(name, arg).map_err(python_error)?;
}
}
- let result = self
- .callable
- .bind(py)
- .call(
- PyTuple::new(py, positional).map_err(python_error)?,
- Some(&kwargs),
- )
- .map_err(|error| python_traceback_error(py, error))?;
+ let positional = PyTuple::new(py,
positional).map_err(python_error)?;
+ let call = self
+ .task
+ .as_deref()
+ .map(|task| RunningCall::enter(py, task))
Review Comment:
Fixed in 3d9fe9ec9. `evaluate_named` checks the killed flag before
`Python::attach`, and `RunningCall::enter` keeps its check for a kill that
lands while the batch's arguments are imported.
--
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]