use datafusion::arrow::record_batch::RecordBatch;
use datafusion::execution::disk_manager::{DiskManagerBuilder, DiskManagerMode};
use datafusion::execution::runtime_env::RuntimeEnvBuilder;
use datafusion::prelude::{DataFrame, SessionConfig, SessionContext};
use futures::StreamExt;
use std::future::Future;
use std::sync::OnceLock;
use std::time::{Duration, Instant};
pub fn query_runtime() -> &'static tokio::runtime::Runtime {
static RUNTIME: OnceLock<tokio::runtime::Runtime> = OnceLock::new();
RUNTIME.get_or_init(|| {
tokio::runtime::Builder::new_multi_thread()
.worker_threads(std::thread::available_parallelism().map_or(2, |n| n.get()))
.thread_name("tableski-query")
.enable_all()
.build()
.expect("query runtime")
})
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct QueryLimits {
pub memory_bytes: Option<usize>,
pub timeout: Option<Duration>,
pub max_rows: Option<usize>,
pub max_bytes: Option<usize>,
}
impl QueryLimits {
pub const SAFE_MEMORY_BYTES: usize = 2 * 1024 * 1024 * 1024;
pub const SAFE_TIMEOUT: Duration = Duration::from_secs(60);
pub const SAFE_MAX_ROWS: usize = 10_000;
pub const SAFE_MAX_BYTES: usize = 16 * 1024 * 1024;
pub fn unlimited() -> Self {
Self::default()
}
pub fn safe() -> Self {
Self {
memory_bytes: Some(Self::SAFE_MEMORY_BYTES),
timeout: Some(Self::SAFE_TIMEOUT),
max_rows: Some(Self::SAFE_MAX_ROWS),
max_bytes: Some(Self::SAFE_MAX_BYTES),
}
}
pub fn session_context(&self) -> Result<SessionContext, String> {
let mut runtime = RuntimeEnvBuilder::new();
if let Some(bytes) = self.memory_bytes {
runtime = runtime
.with_memory_limit(bytes, 1.0)
.with_disk_manager_builder(
DiskManagerBuilder::default().with_mode(DiskManagerMode::Disabled),
);
}
let runtime = runtime.build_arc().map_err(|e| e.to_string())?;
Ok(SessionContext::new_with_config_rt(
SessionConfig::new(),
runtime,
))
}
}
#[derive(Debug, Clone, Default)]
pub struct Collected {
pub batches: Vec<RecordBatch>,
pub rows: usize,
pub bytes: usize,
pub truncated: bool,
}
impl Collected {
pub fn truncation_note(&self, limits: &QueryLimits) -> Option<String> {
if !self.truncated {
return None;
}
let cap = match (limits.max_rows, limits.max_bytes) {
(Some(r), _) if self.rows >= r => format!("{r} rows"),
(_, Some(b)) => format!("{} MiB", b / (1024 * 1024)),
(Some(r), None) => format!("{r} rows"),
(None, None) => "the configured cap".to_string(),
};
Some(format!(
"-- result truncated at {cap} ({} rows returned); add a LIMIT / WHERE, aggregate, or use export_result",
self.rows
))
}
}
pub async fn run_bounded<T, F>(work: F, timeout: Option<Duration>, what: &str) -> Result<T, String>
where
T: Send + 'static,
F: Future<Output = Result<T, String>> + Send + 'static,
{
let Some(budget) = timeout else {
return work.await;
};
let started = Instant::now();
let mut task = query_runtime().spawn(work);
match tokio::time::timeout(budget, &mut task).await {
Ok(joined) => joined.map_err(|e| format!("{what} task failed: {e}"))?,
Err(_) => {
task.abort();
Err(format!(
"{what} cancelled after {:.1}s (wall-clock limit {:.0}s; narrow the query or raise --query-timeout-secs)",
started.elapsed().as_secs_f64(),
budget.as_secs_f64()
))
}
}
}
pub async fn collect_limited(df: DataFrame, limits: &QueryLimits) -> Result<Collected, String> {
let (max_rows, max_bytes) = (limits.max_rows, limits.max_bytes);
run_bounded(
collect_capped(df, max_rows, max_bytes),
limits.timeout,
"query",
)
.await
.map_err(|e| {
if e.contains("Resources exhausted") {
format!("{e} (memory pool limit; narrow the query or raise --max-memory-mb)")
} else {
e
}
})
}
async fn collect_capped(
df: DataFrame,
max_rows: Option<usize>,
max_bytes: Option<usize>,
) -> Result<Collected, String> {
let mut stream = df.execute_stream().await.map_err(|e| e.to_string())?;
let mut out = Collected::default();
while let Some(batch) = stream.next().await {
let mut batch = batch.map_err(|e| e.to_string())?;
if let Some(cap) = max_rows {
let room = cap.saturating_sub(out.rows);
if batch.num_rows() > room {
batch = batch.slice(0, room);
out.truncated = true;
}
}
if let Some(cap) = max_bytes {
let size = batch.get_array_memory_size();
if out.bytes + size > cap && !out.batches.is_empty() {
out.truncated = true;
break;
}
if out.bytes + size > cap {
out.truncated = true;
}
}
out.rows += batch.num_rows();
out.bytes += batch.get_array_memory_size();
if batch.num_rows() > 0 {
out.batches.push(batch);
}
if out.truncated {
break;
}
}
Ok(out)
}