mirror of
https://github.com/lancedb/lancedb.git
synced 2026-10-01 17:35:37 +00:00
fix(python): prevent BlobFile finalizer panics on Tokio workers (#4291)
## Cause `io.RawIOBase.__del__` checks `closed` and calls `close()` during GC. Both PyO3 methods entered `block_on`, which panics when GC runs on a LanceDB Tokio worker. ## Fix Track Python blob handle closure atomically so `closed` is synchronous. `close()` marks the handle closed immediately and, when called on a Tokio thread, schedules the underlying async cleanup without blocking that thread. Reads and seeks after close fail immediately; ordinary callers still wait for cleanup. ## Validation - Rebuilt the Python extension with `uv run --extra tests --extra dev maturin develop --extras tests,dev`. - Ran the blob test file and remote blob handle test: 69 passed, 1 skipped. - Ran `cargo fmt --all`, `ruff format .`, and `ruff check .`. Fixes #4283 <!-- lance-gatekeeper-fix:v1 agent=15a7bd545e63dea4b69faa0429f0a684 generation=1 --> Co-authored-by: Gatefixer <313497061+lancedb-gatefixer[bot]@users.noreply.github.com>
This commit is contained in:
@@ -197,6 +197,27 @@ pub fn block_on<F: std::future::Future>(fut: F) -> F::Output {
|
||||
get_runtime().block_on(fut)
|
||||
}
|
||||
|
||||
/// Run a detached task, including when the caller is already on a runtime worker.
|
||||
/// Keep it visible to [`shutdown`] until it finishes.
|
||||
pub fn spawn_background<F>(fut: F)
|
||||
where
|
||||
F: Future<Output = ()> + Send + 'static,
|
||||
{
|
||||
let guard = OutstandingGuard::new();
|
||||
let task = async move {
|
||||
let _guard = guard;
|
||||
fut.await;
|
||||
};
|
||||
match runtime::Handle::try_current() {
|
||||
Ok(handle) => {
|
||||
let _ = handle.spawn(task);
|
||||
}
|
||||
Err(_) => {
|
||||
let _ = get_runtime().spawn(task);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Gracefully quiesce the shared runtime, meant to run at normal process exit.
|
||||
///
|
||||
/// Waits (bounded by `timeout`) for [`OUTSTANDING`] to reach zero -- i.e.
|
||||
|
||||
+40
-6
@@ -1,8 +1,14 @@
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
// SPDX-FileCopyrightText: Copyright The LanceDB Authors
|
||||
use std::{collections::HashMap, sync::Arc};
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
sync::{
|
||||
Arc,
|
||||
atomic::{AtomicBool, Ordering},
|
||||
},
|
||||
};
|
||||
|
||||
use crate::runtime::{block_on, future_into_py};
|
||||
use crate::runtime::{block_on, future_into_py, spawn_background};
|
||||
use crate::{
|
||||
connection::Connection,
|
||||
error::PythonErrorExt,
|
||||
@@ -680,11 +686,23 @@ impl From<lancedb::table::DropColumnsResult> for DropColumnsResult {
|
||||
#[pyclass(name = "BlobFile")]
|
||||
pub struct PyBlobFile {
|
||||
inner: Arc<BlobFile>,
|
||||
closed: AtomicBool,
|
||||
}
|
||||
|
||||
impl PyBlobFile {
|
||||
fn ensure_open(&self) -> PyResult<()> {
|
||||
if self.closed.load(Ordering::Acquire) {
|
||||
Err(PyRuntimeError::new_err("blob file is already closed"))
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[pymethods]
|
||||
impl PyBlobFile {
|
||||
fn read_bytes(self_: PyRef<'_, Self>) -> PyResult<Py<PyBytes>> {
|
||||
self_.ensure_open()?;
|
||||
let inner = self_.inner.clone();
|
||||
let py = self_.py();
|
||||
let bytes = py
|
||||
@@ -694,6 +712,7 @@ impl PyBlobFile {
|
||||
}
|
||||
|
||||
pub fn read(self_: PyRef<'_, Self>) -> PyResult<Bound<'_, PyAny>> {
|
||||
self_.ensure_open()?;
|
||||
let inner = self_.inner.clone();
|
||||
future_into_py(self_.py(), async move {
|
||||
let bytes = inner
|
||||
@@ -705,7 +724,20 @@ impl PyBlobFile {
|
||||
}
|
||||
|
||||
fn close(self_: PyRef<'_, Self>) -> PyResult<()> {
|
||||
if self_.closed.swap(true, Ordering::AcqRel) {
|
||||
return Ok(());
|
||||
}
|
||||
let inner = self_.inner.clone();
|
||||
if tokio::runtime::Handle::try_current().is_ok() {
|
||||
// IOBase.__del__ can call close while cyclic GC runs on a worker.
|
||||
// The status changes immediately; release Lance's async state there.
|
||||
spawn_background(async move {
|
||||
if let Err(error) = inner.close().await {
|
||||
log::warn!("blob close failed: {error}");
|
||||
}
|
||||
});
|
||||
return Ok(());
|
||||
}
|
||||
self_
|
||||
.py()
|
||||
.detach(move || block_on(async move { inner.close().await }))
|
||||
@@ -713,13 +745,11 @@ impl PyBlobFile {
|
||||
}
|
||||
|
||||
fn is_closed(self_: PyRef<'_, Self>) -> bool {
|
||||
let inner = self_.inner.clone();
|
||||
self_
|
||||
.py()
|
||||
.detach(move || block_on(async move { inner.is_closed().await }))
|
||||
self_.closed.load(Ordering::Acquire)
|
||||
}
|
||||
|
||||
fn seek(self_: PyRef<'_, Self>, position: u64) -> PyResult<()> {
|
||||
self_.ensure_open()?;
|
||||
let inner = self_.inner.clone();
|
||||
self_
|
||||
.py()
|
||||
@@ -728,6 +758,7 @@ impl PyBlobFile {
|
||||
}
|
||||
|
||||
fn tell(self_: PyRef<'_, Self>) -> PyResult<u64> {
|
||||
self_.ensure_open()?;
|
||||
let inner = self_.inner.clone();
|
||||
self_
|
||||
.py()
|
||||
@@ -741,6 +772,7 @@ impl PyBlobFile {
|
||||
|
||||
/// Read a blob-local byte range without moving the cursor.
|
||||
fn read_range(self_: PyRef<'_, Self>, offset: u64, length: usize) -> PyResult<Py<PyBytes>> {
|
||||
self_.ensure_open()?;
|
||||
let end = offset
|
||||
.checked_add(length as u64)
|
||||
.ok_or_else(|| PyValueError::new_err("offset + length overflowed"))?;
|
||||
@@ -753,6 +785,7 @@ impl PyBlobFile {
|
||||
}
|
||||
|
||||
fn read_up_to(self_: PyRef<'_, Self>, length: usize) -> PyResult<Py<PyBytes>> {
|
||||
self_.ensure_open()?;
|
||||
let inner = self_.inner.clone();
|
||||
let py = self_.py();
|
||||
let bytes = py
|
||||
@@ -1455,6 +1488,7 @@ impl Table {
|
||||
.map(|handle| {
|
||||
handle.map(|file| PyBlobFile {
|
||||
inner: Arc::new(file),
|
||||
closed: AtomicBool::new(false),
|
||||
})
|
||||
})
|
||||
.collect::<Vec<_>>())
|
||||
|
||||
Reference in New Issue
Block a user