sunchao commented on code in PR #7181:
URL: https://github.com/apache/datafusion-comet/pull/7181#discussion_r4366518997


##########
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:
   [P1] Compute `hasPythonUdf` before registering the listener that calls 
`close()`. Spark can invoke a completion listener immediately when an input 
feeder constructs its parent iterator after the consuming task completes. 
`close()` then frees `plan` before this line reads it. That can terminate the 
executor for any plan in a Python-enabled build, even with native UDF execution 
disabled in the session. Cache the Boolean in the cleanup-protected 
`createdPlan` block.
   
   Evidence: A bounded Spark 4.1.3 `PipedRDD` fixture recorded `completed=true 
closeRanBeforeProbe=true` using an inert handle. At this head, line 232 
publishes the listener, line 375 calls `releasePlan`, and `hasPythonUdf` 
dereferences the released `ExecutionContext` to read `spark_plan`. The base 
constructor made no native plan access after publishing that listener.



##########
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:
   [P2] Keep the watch alive until the last native UDF plan finishes closing. 
Spark sets `isCompleted` before invoking completion listeners. When a consumer 
takes the first batch and the producer is evaluating the next one, 
`releasePlan` can wait in `BatchProducer.stop()` after the monitor exits. A 
kill during that wait cannot interrupt even a pure Python loop, which can 
retain the executor slot indefinitely.
   
   Evidence: A Spark 4.1.3 `take(1)` fixture used the head’s monitor, UDF 
module, and byte-identical `BatchProducer`. After `cancelJobGroup`, it observed 
`completed=true`, `interrupted=true`, zero native interrupts, and both plan 
close and task end pending. A direct interrupt returned `1`, released close, 
and Spark reported `TaskKilled`. [Spark completion 
order](https://github.com/apache/spark/blob/v4.2.0/core/src/main/scala/org/apache/spark/TaskContextImpl.scala#L159-L164)



##########
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:
   [P2] Avoid unconditionally clearing an interrupt that Python already 
delivered. On CPython 3.11 and 3.12, `PyThreadState_SetAsyncExc(id, NULL)` 
re-arms the interpreter-wide pending flag even when no thread has an exception 
to consume. Other UDFs then repeatedly enter the pending-event path. After 
unregistering the call and releasing the mutex, consume any genuinely late 
interrupt on its originating thread before reusing it.
   
   Evidence: For a surviving UDF summing three million integers, median thread 
CPU over 13 samples rose from 100.22 to 111.71 ms on 3.11.15 and from 134.66 to 
154.77 ms on 3.12.13. Both reported a pending flag of `1` with zero target 
threads. A disposable Python checkpoint kept the flag at `0` and also passed 
the case where a C callable returns directly to Rust with an interrupt pending. 
[CPython 
setter](https://github.com/python/cpython/blob/v3.12.13/Python/pystate.c#L1832-L1838)
 · [Pending-event 
handler](https://github.com/python/cpython/blob/v3.12.13/Python/ceval_gil.c#L1065-L1073)



##########
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:
   [P2] Key cancellation by a process-unique token shared by the plans of one 
`TaskContext`. A cancelled local task can remain in native code after its 
`SparkContext` stops. If the replacement context reuses its attempt ID, this 
registration inherits the old `killed` flag and rejects a healthy UDF. Use the 
same token for `watchedTasks` and the native registry.
   
   Evidence: Two sequential Spark 4.1.3 `local[1]` contexts both assigned 
attempt ID `0`. While the first context’s cancelled call was still waiting, the 
replacement’s identity UDF received input `73` and returned `Arrow UDF stopped 
because task 0 was killed` with `isInterrupted=false`. Attempt `1` returned 
`73` for the same UDF and input. [Spark attempt-ID 
contract](https://github.com/apache/spark/blob/v4.1.3/core/src/main/scala/org/apache/spark/TaskContext.scala#L231-L235)



##########
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:
   [P2] Keep the call registered through user-defined result conversion and 
check cancellation before returning the converted batch. Spark 4.2 accepts 
array-like results whose `__arrow_array__` executes Python inside 
`RecordBatch.from_arrays`. Dropping `RunningCall` here makes that code 
invisible to the monitor. A cancelled conversion can keep running and 
ultimately return a successful batch.
   
   Evidence: On CPython 3.11.15 and 3.12.13, two interrupts during a bounded 
`__arrow_array__` loop both returned `0`. After the loop was released, native 
evaluation returned `[73]`. Stock Spark 4.2 accepted the same result shape and 
stopped the worker during conversion on cancellation. [Spark result 
materialization](https://github.com/apache/spark/blob/v4.2.0/python/pyspark/worker.py#L2563-L2581)



##########
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:
   [P2] Check the killed flag before `Python::attach`, and retain this check in 
`RunningCall::enter` for races. A batch submitted after the registration is 
already marked killed currently waits for the GIL and imports its arguments 
before it can fail. An unrelated UDF holding the GIL can therefore retain this 
cancelled task’s executor slot even though its callable will never run.
   
   Evidence: A CPython 3.12.13 probe marked task `101` killed with no active 
calls, then evaluated its next batch while another UDF held the GIL in 
`math.factorial(420000)`. Returning the kill error took 1,481 ms. With a 
pre-attach check, the same evaluation returned in under 0.1 ms.



##########
native/core/src/execution/python_udf.rs:
##########
@@ -600,6 +775,122 @@ mod tests {
         });
     }
 
+    fn running_calls(task_attempt_id: i64) -> Option<usize> {
+        tasks().get(&task_attempt_id).map(|task| task.calls.len())
+    }
+
+    #[test]
+    fn interrupts_running_call_of_killed_task() {
+        const TASK: i64 = -6811;
+        initialize_python().unwrap();
+        // `except Exception` must not swallow the interrupt.
+        let command = Python::attach(|py| {
+            cloudpickled_lambda(
+                py,
+                c"(lambda ns: (exec('def f(a):\\n    n = 0\\n    while 
True:\\n        n += 1\\n        try:\\n            n %= 7\\n        except 
Exception:\\n            pass\\n', ns), ns['f'])[1])({})",
+            )
+        });
+        let task = Arc::new(TaskRegistration::new(TASK));
+        let udf = Arc::new(
+            ArrowPythonUdf::from_command(&command, DataType::Int64, true, 
true, false, "")
+                .unwrap()
+                .with_task(Arc::clone(&task)),
+        );
+        let input: ArrayRef = Arc::new(Int64Array::from(vec![1]));
+        let (sender, receiver) = std::sync::mpsc::channel();
+        {
+            let (udf, input) = (Arc::clone(&udf), Arc::clone(&input));
+            // Not joined: a call that ignores the interrupt would never 
return.
+            std::thread::spawn(move || sender.send(udf.evaluate(&[input], 1)));

Review Comment:
   [P2] Release the worker’s cloned `Arc<ArrowPythonUdf>` before sending the 
result, or join the worker after the timed receive succeeds. The receiver can 
wake while the detached worker still owns the registration. The final assertion 
then intermittently fails even though the Python call was interrupted and 
unregistered correctly.
   
   Evidence: The unmodified `interrupts_running_call_of_killed_task` failed 5 
of 24 isolated CPython 3.12.13 runs at line 825 with actual `Some(0)` and 
expected `None`. A control changing only the worker to drop its `Arc` before 
`send` passed all 24 runs.



##########
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:
   [P2] A first `TaskKilled` can strand a shared Python lock. If a UDF is 
waiting in `logging.Handler.acquire()`, delivery can occur after it acquires 
the `RLock` and before `Handler.handle()` enters its `try/finally`. The call 
exits owning the lock, and healthy tasks using the same handler block. Use 
explicit cooperative cancellation points in the shared interpreter, or isolate 
calls that require forced interruption.
   
   Evidence: On CPython 3.11.15 and 3.12.13, one interrupt made the cancelled 
UDF return `TaskKilled` while still owning the handler lock. A peer’s 
`logger.warning()` timed out after 300 ms and completed when the victim thread 
released the lock. With zero interrupts, all three UDFs completed. This happens 
during acquisition, before the cleanup covered by the earlier review. [CPython 
handler](https://github.com/python/cpython/blob/v3.12.13/Lib/logging/__init__.py#L1025-L1030)



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