This is an automated email from the ASF dual-hosted git repository.
JingsongLi pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/paimon-rust.git
The following commit(s) were added to refs/heads/main by this push:
new 07c0fb45 fix(datafusion): hand off the worker before blocking in
catalog callbacks (#891)
07c0fb45 is described below
commit 07c0fb454658d3d1a83c6198ff0783f29e75c53a
Author: Dapeng Sun(孙大鹏) <[email protected]>
AuthorDate: Mon Sep 21 13:40:30 2026 +0800
fix(datafusion): hand off the worker before blocking in catalog callbacks
(#891)
---
crates/integrations/datafusion/Cargo.toml | 2 +-
crates/integrations/datafusion/src/runtime.rs | 80 +++++++++++++--
.../tests/catalog_callbacks_on_runtime_workers.rs | 112 +++++++++++++++++++++
3 files changed, 184 insertions(+), 10 deletions(-)
diff --git a/crates/integrations/datafusion/Cargo.toml
b/crates/integrations/datafusion/Cargo.toml
index f7072fc3..a2f78ae2 100644
--- a/crates/integrations/datafusion/Cargo.toml
+++ b/crates/integrations/datafusion/Cargo.toml
@@ -43,7 +43,7 @@ paimon = { workspace = true }
futures = "0.3"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
-tokio = { workspace = true, features = ["rt", "time", "fs"] }
+tokio = { workspace = true, features = ["rt", "rt-multi-thread", "time", "fs"]
}
lexical-write-float = "1.0.6"
uuid = { version = "1", features = ["v4"] }
diff --git a/crates/integrations/datafusion/src/runtime.rs
b/crates/integrations/datafusion/src/runtime.rs
index aed51093..6fc9bf0d 100644
--- a/crates/integrations/datafusion/src/runtime.rs
+++ b/crates/integrations/datafusion/src/runtime.rs
@@ -15,11 +15,12 @@
// specific language governing permissions and limitations
// under the License.
+use std::cell::Cell;
use std::future::Future;
use std::sync::atomic::{AtomicPtr, Ordering};
use std::sync::OnceLock;
-use tokio::runtime::{Handle, Runtime};
+use tokio::runtime::{Builder, Handle, Runtime};
struct ProcessRuntime {
pid: u32,
@@ -28,6 +29,18 @@ struct ProcessRuntime {
static RUNTIME: AtomicPtr<ProcessRuntime> =
AtomicPtr::new(std::ptr::null_mut());
+thread_local! {
+ // Set on every thread the process runtime starts, where `block_in_place`
is allowed.
+ static ON_PROCESS_RUNTIME_THREAD: Cell<bool> = const { Cell::new(false) };
+}
+
+fn build_process_runtime() -> std::io::Result<Runtime> {
+ Builder::new_multi_thread()
+ .enable_all()
+ .on_thread_start(|| ON_PROCESS_RUNTIME_THREAD.with(|flag|
flag.set(true)))
+ .build()
+}
+
fn global_runtime() -> &'static Runtime {
let pid = std::process::id();
let mut current = RUNTIME.load(Ordering::Acquire);
@@ -38,7 +51,7 @@ fn global_runtime() -> &'static Runtime {
let state = unsafe { &*current };
if state.pid == pid {
return state.runtime.get_or_init(|| {
- Runtime::new().expect(
+ build_process_runtime().expect(
"failed to build global tokio runtime for paimon
datafusion integration",
)
});
@@ -103,14 +116,19 @@ where
F: Future + Send + 'static,
F::Output: Send + 'static,
{
- if Handle::try_current().is_ok() {
- let handle = global_runtime().handle().clone();
- std::thread::spawn(move || handle.block_on(future))
- .join()
- .expect(panic_error)
- } else {
- global_runtime().block_on(future)
+ if Handle::try_current().is_err() {
+ return global_runtime().block_on(future);
+ }
+ if ON_PROCESS_RUNTIME_THREAD.with(Cell::get) {
+ // A worker blocked mid-poll strands its LIFO slot and the I/O driver
it last parked on.
+ // `block_in_place` hands both to another thread first.
+ return tokio::task::block_in_place(||
global_runtime().block_on(future));
}
+ // Threads of other runtimes: `block_in_place` panics in a `LocalSet` or a
current-thread runtime.
+ let handle = global_runtime().handle().clone();
+ std::thread::spawn(move || handle.block_on(future))
+ .join()
+ .expect(panic_error)
}
#[cfg(test)]
@@ -142,6 +160,50 @@ mod tests {
});
}
+ #[test]
+ fn blocking_on_a_process_runtime_worker_keeps_its_queued_tasks_running() {
+ let (done_tx, done_rx) = std::sync::mpsc::channel();
+ global_runtime().spawn(async move {
+ let (value_tx, value_rx) = tokio::sync::oneshot::channel();
+ // Spawned from a worker, this lands in that worker's LIFO slot,
which cannot be stolen.
+ tokio::spawn(async move {
+ let _ = value_tx.send(7);
+ });
+ let value = block_on_with_runtime(
+ async move { value_rx.await.unwrap() },
+ "blocking runtime test panicked",
+ );
+ done_tx.send(value).unwrap();
+ });
+ assert_eq!(
+ done_rx.recv_timeout(std::time::Duration::from_secs(30)),
+ Ok(7),
+ "the blocked worker stranded the task it had just queued"
+ );
+ }
+
+ #[test]
+ fn callers_outside_the_process_runtime_keep_the_thread_based_path() {
+ let block = || block_on_with_runtime(async { 7 }, "blocking runtime
test panicked");
+
+ // `block_in_place` would panic in both of these.
+ let multi_thread = tokio::runtime::Builder::new_multi_thread()
+ .worker_threads(2)
+ .build()
+ .unwrap();
+ let local = tokio::task::LocalSet::new();
+ let in_local_set = local.block_on(&multi_thread, async move {
+ let spawned = tokio::task::spawn_local(async move { block() });
+ (block(), spawned.await.unwrap())
+ });
+ assert_eq!(in_local_set, (7, 7));
+
+ let current_thread = tokio::runtime::Builder::new_current_thread()
+ .build()
+ .unwrap();
+ assert_eq!(current_thread.block_on(async move { block() }), 7);
+ }
+
#[test]
fn entered_runtime_is_preserved() {
let local = tokio::runtime::Builder::new_current_thread()
diff --git
a/crates/integrations/datafusion/tests/catalog_callbacks_on_runtime_workers.rs
b/crates/integrations/datafusion/tests/catalog_callbacks_on_runtime_workers.rs
new file mode 100644
index 00000000..84ac5e68
--- /dev/null
+++
b/crates/integrations/datafusion/tests/catalog_callbacks_on_runtime_workers.rs
@@ -0,0 +1,112 @@
+// 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.
+
+//! `information_schema` queries whose plan spawns tasks, so the synchronous
catalog callbacks
+//! run on a worker of the process runtime. Driven like the Python binding,
from a plain thread.
+
+#[path = "../../../paimon/tests/mock_server.rs"]
+mod mock_server;
+
+use std::collections::HashMap;
+use std::sync::{mpsc, Arc};
+use std::time::Duration;
+
+use arrow_array::RecordBatch;
+use paimon::api::ConfigResponse;
+use paimon::catalog::RESTCatalog;
+use paimon::spec::{DataType, IntType, Schema};
+use paimon::{CatalogOptions, Options};
+use paimon_datafusion::SQLContext;
+
+use mock_server::start_mock_server;
+
+const WAREHOUSE: &str = "test_warehouse";
+const DATABASES: [&str; 2] = ["db_a", "db_b"];
+
+/// `(query, rows)`. Every table has two columns and every database has one
table.
+const QUERIES: [(&str, usize); 2] = [
+ // With two or more target partitions the filter sits on a
`RepartitionExec`.
+ (
+ "SELECT column_name FROM paimon.information_schema.columns WHERE
table_schema = 'db_a'",
+ 2,
+ ),
+ // Two output partitions are merged by spawned tasks whatever the number
of cores.
+ (
+ "SELECT column_name FROM paimon.information_schema.columns \
+ UNION ALL SELECT column_name FROM paimon.information_schema.columns",
+ 8,
+ ),
+];
+
+#[test]
+fn information_schema_queries_finish_when_the_plan_spawns_tasks() {
+ // The catalog service gets its own runtime, so the runtime under test
cannot starve it.
+ let server_runtime = tokio::runtime::Builder::new_multi_thread()
+ .worker_threads(2)
+ .enable_all()
+ .build()
+ .unwrap();
+ let temp_dir = tempfile::tempdir().unwrap();
+ let server = server_runtime.block_on(start_mock_server(
+ WAREHOUSE.to_string(),
+ temp_dir.path().to_string_lossy().into_owned(),
+ ConfigResponse::new(HashMap::from([(
+ CatalogOptions::PREFIX.to_string(),
+ "mock-test".to_string(),
+ )])),
+ DATABASES.iter().map(|name| name.to_string()).collect(),
+ ));
+ for database in DATABASES {
+ let schema = Schema::builder()
+ .column("id", DataType::Int(IntType::new()))
+ .column("v", DataType::Int(IntType::new()))
+ .build()
+ .unwrap();
+ let path = format!("file://{}/{database}.db/t",
temp_dir.path().display());
+ server.add_table_with_schema(database, "t", schema, &path);
+ }
+ let url = server.url().unwrap();
+
+ let (rows_tx, rows_rx) = mpsc::channel();
+ // No runtime is entered on this thread, so `runtime()` is the process
runtime.
+ std::thread::spawn(move || {
+ paimon_datafusion::runtime::runtime().block_on(async move {
+ let mut options = Options::new();
+ options.set(CatalogOptions::URI, url);
+ options.set(CatalogOptions::WAREHOUSE, WAREHOUSE);
+ options.set(CatalogOptions::TOKEN_PROVIDER, "bear");
+ options.set(CatalogOptions::TOKEN, "test-token");
+ let catalog = Arc::new(RESTCatalog::new(options,
true).await.unwrap());
+ let mut context = SQLContext::new();
+ context.register_catalog("paimon", catalog).await.unwrap();
+
+ for (query, _) in QUERIES {
+ let batches =
context.sql(query).await.unwrap().collect().await.unwrap();
+ let rows: usize =
batches.iter().map(RecordBatch::num_rows).sum();
+ rows_tx.send(rows).unwrap();
+ }
+ });
+ });
+
+ for (query, expected_rows) in QUERIES {
+ // A timer of the blocked runtime would never fire, so the deadline
lives on this thread.
+ let rows = rows_rx
+ .recv_timeout(Duration::from_secs(60))
+ .unwrap_or_else(|_| panic!("a catalog callback blocked a runtime
worker: {query}"));
+ assert_eq!(rows, expected_rows, "{query}");
+ }
+}