Skip to content

Commit 8db5b71

Browse files
committed
fix(python/sedonadb): share one Tokio runtime across contexts
Building a multi-threaded Tokio runtime per SedonaContext spawns num_cpus workers plus a blocking pool per session. Under a free-threaded interpreter (no GIL to serialize the Python-touching workers) these pile up across many short-lived sessions and exhaust the OS per-process thread limit, crashing the free-threaded macOS wheel. Share a single process-global runtime so the worker and blocking pools are bounded per process instead of per session, while queries keep the full num_cpus worker pool.
1 parent 0163787 commit 8db5b71

1 file changed

Lines changed: 33 additions & 11 deletions

File tree

python/sedonadb/src/context.rs

Lines changed: 33 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,10 @@
1414
// KIND, either express or implied. See the License for the
1515
// specific language governing permissions and limitations
1616
// under the License.
17-
use std::{collections::HashMap, sync::Arc};
17+
use std::{
18+
collections::HashMap,
19+
sync::{Arc, OnceLock},
20+
};
1821

1922
use arrow_schema::DataType;
2023
use pyo3::prelude::*;
@@ -61,27 +64,46 @@ fn stringify_options(
6164
.collect()
6265
}
6366

67+
/// The process-wide Tokio runtime shared by every [`InternalContext`].
68+
///
69+
/// One multi-threaded runtime backs all contexts so the worker and
70+
/// blocking-thread pools are bounded per process rather than per session.
71+
/// Building a runtime per `connect()` meant opening N sessions spawned N
72+
/// runtimes' worth of threads; under a free-threaded interpreter (no GIL to
73+
/// serialize the Python-touching workers) those piled up across short-lived
74+
/// sessions and exhausted the OS per-process thread limit. A single shared
75+
/// runtime keeps full `num_cpus` parallelism for queries while making a new
76+
/// session cost no additional threads. It lives for the process, so it is
77+
/// never torn down on an interpreter thread.
78+
fn shared_runtime() -> Result<Arc<RuntimeHandle>, PySedonaError> {
79+
static SHARED: OnceLock<Arc<RuntimeHandle>> = OnceLock::new();
80+
if let Some(runtime) = SHARED.get() {
81+
return Ok(runtime.clone());
82+
}
83+
let runtime = tokio::runtime::Builder::new_multi_thread()
84+
.enable_all()
85+
.build()
86+
.map_err(|e| PySedonaError::SedonaPython(format!("Failed to build shared runtime: {e}")))?;
87+
// On the rare initialization race, the losing thread's freshly built handle
88+
// is dropped here (draining on a janitor thread, never the caller's), and
89+
// the winner's runtime is returned.
90+
let handle = Arc::new(RuntimeHandle::new(runtime));
91+
Ok(SHARED.get_or_init(|| handle).clone())
92+
}
93+
6494
#[pymethods]
6595
impl InternalContext {
6696
#[new]
6797
#[pyo3(signature = (options=HashMap::new()))]
6898
fn new(py: Python, options: HashMap<String, String>) -> Result<Self, PySedonaError> {
69-
let runtime = tokio::runtime::Builder::new_multi_thread()
70-
.enable_all()
71-
.build()
72-
.map_err(|e| {
73-
PySedonaError::SedonaPython(format!("Failed to build multithreaded runtime: {e}"))
74-
})?;
99+
let runtime = shared_runtime()?;
75100

76101
let builder = SedonaContextBuilder::from_options(&options)
77102
.map_err(|e| PySedonaError::SedonaPython(e.to_string()))?;
78103

79104
let inner = wait_for_future(py, &runtime, builder.build())??;
80105

81-
Ok(Self {
82-
inner,
83-
runtime: Arc::new(RuntimeHandle::new(runtime)),
84-
})
106+
Ok(Self { inner, runtime })
85107
}
86108

87109
pub fn view<'py>(

0 commit comments

Comments
 (0)