1use datafusion::arrow::record_batch::RecordBatch;
6use datafusion::execution::disk_manager::{DiskManagerBuilder, DiskManagerMode};
7use datafusion::execution::runtime_env::RuntimeEnvBuilder;
8use datafusion::prelude::{DataFrame, SessionConfig, SessionContext};
9use futures::StreamExt;
10use std::future::Future;
11use std::sync::OnceLock;
12use std::time::{Duration, Instant};
13
14pub fn query_runtime() -> &'static tokio::runtime::Runtime {
18 static RUNTIME: OnceLock<tokio::runtime::Runtime> = OnceLock::new();
19 RUNTIME.get_or_init(|| {
20 tokio::runtime::Builder::new_multi_thread()
21 .worker_threads(std::thread::available_parallelism().map_or(2, |n| n.get()))
22 .thread_name("tableski-query")
23 .enable_all()
24 .build()
25 .expect("query runtime")
26 })
27}
28
29#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
31pub struct QueryLimits {
32 pub memory_bytes: Option<usize>,
36 pub timeout: Option<Duration>,
38 pub max_rows: Option<usize>,
40 pub max_bytes: Option<usize>,
42}
43
44impl QueryLimits {
45 pub const SAFE_MEMORY_BYTES: usize = 2 * 1024 * 1024 * 1024;
47 pub const SAFE_TIMEOUT: Duration = Duration::from_secs(60);
49 pub const SAFE_MAX_ROWS: usize = 10_000;
51 pub const SAFE_MAX_BYTES: usize = 16 * 1024 * 1024;
53
54 pub fn unlimited() -> Self {
56 Self::default()
57 }
58
59 pub fn safe() -> Self {
61 Self {
62 memory_bytes: Some(Self::SAFE_MEMORY_BYTES),
63 timeout: Some(Self::SAFE_TIMEOUT),
64 max_rows: Some(Self::SAFE_MAX_ROWS),
65 max_bytes: Some(Self::SAFE_MAX_BYTES),
66 }
67 }
68
69 pub fn session_context(&self) -> Result<SessionContext, String> {
72 let mut runtime = RuntimeEnvBuilder::new();
73 if let Some(bytes) = self.memory_bytes {
74 runtime = runtime
75 .with_memory_limit(bytes, 1.0)
76 .with_disk_manager_builder(
77 DiskManagerBuilder::default().with_mode(DiskManagerMode::Disabled),
78 );
79 }
80 let runtime = runtime.build_arc().map_err(|e| e.to_string())?;
81 Ok(SessionContext::new_with_config_rt(
82 SessionConfig::new(),
83 runtime,
84 ))
85 }
86}
87
88#[derive(Debug, Clone, Default)]
90pub struct Collected {
91 pub batches: Vec<RecordBatch>,
93 pub rows: usize,
95 pub bytes: usize,
97 pub truncated: bool,
99}
100
101impl Collected {
102 pub fn truncation_note(&self, limits: &QueryLimits) -> Option<String> {
104 if !self.truncated {
105 return None;
106 }
107 let cap = match (limits.max_rows, limits.max_bytes) {
108 (Some(r), _) if self.rows >= r => format!("{r} rows"),
109 (_, Some(b)) => format!("{} MiB", b / (1024 * 1024)),
110 (Some(r), None) => format!("{r} rows"),
111 (None, None) => "the configured cap".to_string(),
112 };
113 Some(format!(
114 "-- result truncated at {cap} ({} rows returned); add a LIMIT / WHERE, aggregate, or use export_result",
115 self.rows
116 ))
117 }
118}
119
120pub async fn run_bounded<T, F>(work: F, timeout: Option<Duration>, what: &str) -> Result<T, String>
124where
125 T: Send + 'static,
126 F: Future<Output = Result<T, String>> + Send + 'static,
127{
128 let Some(budget) = timeout else {
129 return work.await;
130 };
131 let started = Instant::now();
132 let mut task = query_runtime().spawn(work);
133 match tokio::time::timeout(budget, &mut task).await {
134 Ok(joined) => joined.map_err(|e| format!("{what} task failed: {e}"))?,
135 Err(_) => {
136 task.abort();
137 Err(format!(
138 "{what} cancelled after {:.1}s (wall-clock limit {:.0}s; narrow the query or raise --query-timeout-secs)",
139 started.elapsed().as_secs_f64(),
140 budget.as_secs_f64()
141 ))
142 }
143 }
144}
145
146pub async fn collect_limited(df: DataFrame, limits: &QueryLimits) -> Result<Collected, String> {
153 let (max_rows, max_bytes) = (limits.max_rows, limits.max_bytes);
154 run_bounded(
155 collect_capped(df, max_rows, max_bytes),
156 limits.timeout,
157 "query",
158 )
159 .await
160 .map_err(|e| {
161 if e.contains("Resources exhausted") {
162 format!("{e} (memory pool limit; narrow the query or raise --max-memory-mb)")
163 } else {
164 e
165 }
166 })
167}
168
169async fn collect_capped(
170 df: DataFrame,
171 max_rows: Option<usize>,
172 max_bytes: Option<usize>,
173) -> Result<Collected, String> {
174 let mut stream = df.execute_stream().await.map_err(|e| e.to_string())?;
175 let mut out = Collected::default();
176 while let Some(batch) = stream.next().await {
177 let mut batch = batch.map_err(|e| e.to_string())?;
178 if let Some(cap) = max_rows {
179 let room = cap.saturating_sub(out.rows);
180 if batch.num_rows() > room {
181 batch = batch.slice(0, room);
182 out.truncated = true;
183 }
184 }
185 if let Some(cap) = max_bytes {
186 let size = batch.get_array_memory_size();
187 if out.bytes + size > cap && !out.batches.is_empty() {
188 out.truncated = true;
189 break;
190 }
191 if out.bytes + size > cap {
192 out.truncated = true;
193 }
194 }
195 out.rows += batch.num_rows();
196 out.bytes += batch.get_array_memory_size();
197 if batch.num_rows() > 0 {
198 out.batches.push(batch);
199 }
200 if out.truncated {
201 break;
202 }
203 }
204 Ok(out)
205}