mirror of
https://github.com/lancedb/lancedb.git
synced 2026-09-21 12:35:42 +00:00
Short-lived Python processes using this client can occasionally crash with SIGABRT during interpreter shutdown, even after every operation they ran completed successfully. The cause is the shared Tokio runtime backing every async call: it's never told to shut down at normal process exit, only reset (and deliberately leaked) on `fork()`. Its worker threads keep running, uncoordinated with the interpreter, until the process actually ends, and if one is mid-task exactly as `Py_Finalize` starts tearing down interpreter state, it can panic on state that's already gone. That panic happens on a background thread with no PyO3-wrapped call frame to catch it, so Rust aborts the whole process instead of just failing that one call. This PR gives the runtime a coordinated, bounded shutdown by registering a Python `atexit` callback that runs while the interpreter is still fully valid. Getting the exit lifecycle right took a few rounds of review. Earlier versions freed the runtime as soon as `Arc::strong_count` looked low, but that's the wrong signal — it reflects who currently holds a reference, not who's logically still in flight. That mistake showed up three ways: a caller could dereference memory already freed out from under it; an install already in progress could finish invisibly after `shutdown()` had already decided there was nothing to do; and a spawned task could end up as the final owner of the `Runtime`, so completing it dropped the runtime from inside one of its own worker threads, which Tokio itself forbids and panics on (this reproduced unprompted in this branch's own test suite). Fixing all three meant replacing reference-count-based tracking with an explicit counter of in-flight top-level calls that `shutdown()` waits on directly. This was accomplished with the following changes: - The runtime lives in an `ArcSwapOption<Tagged>`, where `Tagged` pairs the `Runtime` with the fork generation it was built in. - An `OUTSTANDING` counter, incremented before a top-level `spawn`/`spawn_blocking`/`block_on` call does anything else and decremented only once it has truly finished (via an `OutstandingGuard` token that carries no reference to the runtime), is what `shutdown()` waits on — not `Arc::strong_count` or whether the slot looks empty. This closes the install-race and makes it impossible for a task's own completion to be the runtime's final drop. - Once `shutdown()`'s bound elapses, it stops waiting and attempts retirement anyway, rather than returning with the runtime and its workers left fully alive. - `spawn`/`spawn_blocking` use `Handle::try_current()` to pin any nested spawn (`future_into_py` spawns a task that itself spawns a second one for the real work) to whichever runtime is already executing it, so a reclaim landing between the two calls can't split one logical operation across two different runtime instances. - The fork-child handler now only bumps a bare `GENERATION` counter — no `ArcSwapOption` call of any kind from that context, since `swap`/`compare_and_swap` do real reader-reconciliation work (thread-local state, potentially an allocation) that isn't safe in a forked child. `get_runtime()` compares its installed runtime's generation against the live counter from ordinary context and rebuilds on a mismatch. - Registered `shutdown_runtime` as a Python `atexit` callback in the `_lancedb` module init, running with the GIL released (`Python::detach`) since the bounded wait could otherwise deadlock against any in-flight task that itself needs the GIL. ### Testing - Unit tests in `runtime.rs` cover: shutdown with no runtime created, shutdown after use and lazy rebuild afterward, calling shutdown twice in a row, a concurrent stress test racing many threads against shutdown, a nested-spawn test reproducing `future_into_py`'s own spawn-within-a-spawn shape under concurrent shutdown, a test confirming a top-level task in flight survives a concurrent shutdown reclaim, and a test forcing the install-vs-shutdown race directly. - Built the wheel and ran a concurrent reproducer (many threads hammering the client while `atexit` fires) over 100 times with no hangs or crashes, plus a 30-second-join variant and repeated runs of a short-lived process confirming clean exits with no added latency. --------- Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
123 lines
4.7 KiB
Rust
123 lines
4.7 KiB
Rust
// SPDX-License-Identifier: Apache-2.0
|
|
// SPDX-FileCopyrightText: Copyright The LanceDB Authors
|
|
|
|
use arrow::RecordBatchStream;
|
|
use connection::{Connection, connect, connect_namespace, connect_namespace_client};
|
|
use env_logger::Env;
|
|
use expr::{PyExpr, expr_col, expr_func, expr_lit};
|
|
use index::IndexConfig;
|
|
use permutation::{PyAsyncPermutationBuilder, PyPermutationReader};
|
|
use pyo3::{
|
|
Bound, PyResult, Python, pyfunction, pymodule,
|
|
types::{PyAnyMethods, PyModule, PyModuleMethods},
|
|
wrap_pyfunction,
|
|
};
|
|
use query::{FTSQuery, HybridQuery, Query, VectorQuery};
|
|
use session::Session;
|
|
use table::{
|
|
AddColumnsResult, AddResult, AlterColumnsResult, DeleteResult, DropColumnsResult, FtsToken,
|
|
LsmWriteSpec, MergeResult, PyBlobFile, RefreshColumnResult, RefreshMaterializedViewResult,
|
|
Table, UpdateFieldMetadataResult, UpdateResult,
|
|
};
|
|
|
|
pub mod arrow;
|
|
pub mod connection;
|
|
pub mod error;
|
|
pub mod expr;
|
|
pub mod header;
|
|
pub mod index;
|
|
pub mod job;
|
|
pub mod namespace;
|
|
pub mod oauth;
|
|
pub mod otel;
|
|
pub mod permutation;
|
|
pub mod query;
|
|
pub mod runtime;
|
|
pub mod session;
|
|
pub mod sql;
|
|
pub mod table;
|
|
pub mod util;
|
|
|
|
/// Shut down the shared Tokio runtime (see `runtime::shutdown`).
|
|
///
|
|
/// Registered below as a Python `atexit` callback rather than called
|
|
/// directly: `atexit` runs while the interpreter is still fully valid,
|
|
/// which is the coordinated, bounded exit the runtime otherwise never gets.
|
|
///
|
|
/// Runs the actual wait with the GIL released (`Python::detach`): shutdown
|
|
/// blocks the calling thread waiting on the runtime's own worker threads,
|
|
/// and if any in-flight task needs the GIL to finish (e.g. one that calls
|
|
/// back into Python), holding it here while waiting on that same task would
|
|
/// deadlock rather than time out.
|
|
#[pyfunction]
|
|
fn shutdown_runtime(py: Python<'_>) {
|
|
py.detach(|| runtime::shutdown(std::time::Duration::from_secs(5)));
|
|
}
|
|
|
|
#[pymodule]
|
|
pub fn _lancedb(py: Python, m: &Bound<'_, PyModule>) -> PyResult<()> {
|
|
let env = Env::new()
|
|
.filter_or("LANCEDB_LOG", "warn")
|
|
.write_style("LANCEDB_LOG_STYLE");
|
|
env_logger::init_from_env(env);
|
|
m.add_class::<Connection>()?;
|
|
m.add_class::<Session>()?;
|
|
m.add_class::<Table>()?;
|
|
m.add_class::<crate::oauth::PyOAuthSession>()?;
|
|
m.add_class::<crate::oauth::PySessionStatus>()?;
|
|
m.add_class::<crate::oauth::PySessionLogout>()?;
|
|
m.add_class::<crate::job::Job>()?;
|
|
m.add_class::<crate::job::JobInfo>()?;
|
|
m.add_class::<crate::job::JobDescription>()?;
|
|
m.add_class::<crate::job::JobFailureInfo>()?;
|
|
m.add_class::<crate::sql::Query>()?;
|
|
m.add_class::<crate::sql::QueryDescription>()?;
|
|
m.add_class::<PyBlobFile>()?;
|
|
m.add_class::<IndexConfig>()?;
|
|
m.add_class::<Query>()?;
|
|
m.add_class::<FTSQuery>()?;
|
|
m.add_class::<HybridQuery>()?;
|
|
m.add_class::<VectorQuery>()?;
|
|
m.add_class::<RecordBatchStream>()?;
|
|
m.add_class::<AddColumnsResult>()?;
|
|
m.add_class::<RefreshColumnResult>()?;
|
|
m.add_class::<RefreshMaterializedViewResult>()?;
|
|
m.add_class::<AlterColumnsResult>()?;
|
|
m.add_class::<UpdateFieldMetadataResult>()?;
|
|
m.add_class::<AddResult>()?;
|
|
m.add_class::<MergeResult>()?;
|
|
m.add_class::<LsmWriteSpec>()?;
|
|
m.add_class::<DeleteResult>()?;
|
|
m.add_class::<DropColumnsResult>()?;
|
|
m.add_class::<UpdateResult>()?;
|
|
m.add_class::<FtsToken>()?;
|
|
m.add_class::<PyAsyncPermutationBuilder>()?;
|
|
m.add_class::<PyPermutationReader>()?;
|
|
m.add_class::<PyExpr>()?;
|
|
// OpenTelemetry metrics bridge
|
|
m.add_class::<otel::PyMetricPoint>()?;
|
|
m.add_class::<otel::PyMetricDescription>()?;
|
|
m.add_function(wrap_pyfunction!(
|
|
otel::register_lancedb_metrics_recorder,
|
|
m
|
|
)?)?;
|
|
m.add_function(wrap_pyfunction!(otel::lancedb_metrics_catalog, m)?)?;
|
|
m.add_function(wrap_pyfunction!(otel::snapshot_lancedb_metrics, m)?)?;
|
|
m.add_function(wrap_pyfunction!(connect, m)?)?;
|
|
m.add_function(wrap_pyfunction!(connect_namespace, m)?)?;
|
|
m.add_function(wrap_pyfunction!(connect_namespace_client, m)?)?;
|
|
m.add_function(wrap_pyfunction!(table::tokenize, m)?)?;
|
|
m.add_function(wrap_pyfunction!(permutation::async_permutation_builder, m)?)?;
|
|
m.add_function(wrap_pyfunction!(util::validate_table_name, m)?)?;
|
|
m.add_function(wrap_pyfunction!(query::fts_query_to_json, m)?)?;
|
|
m.add_function(wrap_pyfunction!(expr_col, m)?)?;
|
|
m.add_function(wrap_pyfunction!(expr_lit, m)?)?;
|
|
m.add_function(wrap_pyfunction!(expr_func, m)?)?;
|
|
m.add("__version__", env!("CARGO_PKG_VERSION"))?;
|
|
// Give the shared runtime a coordinated, bounded shutdown at normal
|
|
// process exit -- see `shutdown_runtime` and `runtime::shutdown` for why.
|
|
py.import("atexit")?
|
|
.call_method1("register", (wrap_pyfunction!(shutdown_runtime, m)?,))?;
|
|
Ok(())
|
|
}
|