1use std::sync::Arc;
4use std::time::Instant;
5
6use ballista::prelude::SessionContextExt;
7use ballista_core::extension::SessionConfigExt;
8use datafusion::arrow::array::RecordBatch;
9use datafusion::arrow::datatypes::SchemaRef;
10use datafusion::dataframe::DataFrame;
11use datafusion::execution::SessionStateBuilder;
12use datafusion::physical_plan::displayable;
13use datafusion::prelude::{SessionConfig, SessionContext};
14use oxidelake_compute::{local_backend, oxide_udfs};
15use oxidelake_core::telemetry::{TelemetryHub, TierCapacity};
16use oxidelake_core::{BackendKind, EngineError};
17use oxidelake_planner::{HardwarePlacementRule, physical_optimizer_rules};
18use oxidelake_storage::{
19 default_object_store, register_local_store, register_parquet_table, with_gpu_batch_size,
20 with_pruning,
21};
22
23use crate::cluster;
24
25#[derive(Debug, Clone, PartialEq, Eq)]
27#[non_exhaustive]
28pub enum SessionMode {
29 Embedded {
31 target: BackendKind,
33 },
34 Cluster {
36 scheduler_url: String,
38 },
39}
40
41fn local_capacity() -> TierCapacity {
55 let Ok(backend) = local_backend() else {
56 return TierCapacity::default();
57 };
58 let Ok(info) = backend.memory_info() else {
59 return TierCapacity::default();
60 };
61 if backend.kind().is_gpu() {
62 TierCapacity {
63 device_bytes: Some(info.total_bytes),
64 host_bytes: None,
65 spill_on_query_path: false,
66 }
67 } else {
68 TierCapacity {
69 device_bytes: None,
70 host_bytes: Some(info.total_bytes),
71 spill_on_query_path: false,
72 }
73 }
74}
75
76impl std::fmt::Display for SessionMode {
77 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
80 match self {
81 SessionMode::Embedded { target } => write!(f, "embedded/{target}"),
82 SessionMode::Cluster { scheduler_url } => write!(f, "cluster/{scheduler_url}"),
83 }
84 }
85}
86
87#[derive(Debug, Clone, Default, PartialEq, Eq)]
94#[non_exhaustive]
95pub struct SessionOptions {
96 pub target: Option<BackendKind>,
98 pub batch_size: Option<usize>,
101}
102
103impl SessionOptions {
104 pub fn new() -> Self {
106 Self::default()
107 }
108
109 pub fn with_target(mut self, target: BackendKind) -> Self {
111 self.target = Some(target);
112 self
113 }
114
115 pub fn with_batch_size(mut self, rows: usize) -> Self {
117 self.batch_size = Some(rows);
118 self
119 }
120
121 fn apply(
128 &self,
129 config: SessionConfig,
130 target: Option<BackendKind>,
131 ) -> Result<SessionConfig, EngineError> {
132 match self.batch_size {
133 Some(0) => Err(EngineError::plan(
134 "batch size must be at least 1 row (0 would make every query return nothing)",
135 )),
136 Some(rows) => Ok(config.with_batch_size(rows)),
137 None if target.is_some_and(BackendKind::is_gpu) => Ok(with_gpu_batch_size(config)),
138 None => Ok(config),
139 }
140 }
141}
142
143pub struct OxideSession {
145 ctx: SessionContext,
146 mode: SessionMode,
147 telemetry: Arc<TelemetryHub>,
148}
149
150impl OxideSession {
151 pub fn local() -> Result<Self, EngineError> {
155 Self::local_with_options(&SessionOptions::new())
156 }
157
158 pub fn local_with_target(target: BackendKind) -> Result<Self, EngineError> {
164 Self::local_with_options(&SessionOptions::new().with_target(target))
165 }
166
167 pub fn local_with_options(options: &SessionOptions) -> Result<Self, EngineError> {
169 let target = match options.target {
170 Some(target) => target,
171 None => local_backend()?.kind(),
172 };
173 let telemetry = TelemetryHub::new();
174 telemetry.set_capacity(local_capacity());
175 let rule = HardwarePlacementRule::new(target).with_telemetry(Arc::clone(&telemetry));
176 let config = options.apply(with_pruning(SessionConfig::new()), Some(target))?;
177 let state = SessionStateBuilder::new()
178 .with_default_features()
179 .with_config(config)
180 .with_physical_optimizer_rules(physical_optimizer_rules(rule))
181 .build();
182 let ctx = SessionContext::new_with_state(state);
183 register_local_store(&ctx, default_object_store());
184 for udf in oxide_udfs() {
185 ctx.register_udf(udf.as_ref().clone());
186 }
187 Ok(Self {
188 ctx,
189 mode: SessionMode::Embedded { target },
190 telemetry,
191 })
192 }
193
194 pub async fn connect(scheduler_url: &str) -> Result<Self, EngineError> {
199 Self::connect_with_options(scheduler_url, &SessionOptions::new()).await
200 }
201
202 pub async fn connect_with_options(
206 scheduler_url: &str,
207 options: &SessionOptions,
208 ) -> Result<Self, EngineError> {
209 let config = with_pruning(SessionConfig::new_with_ballista())
210 .with_ballista_physical_extension_codec(cluster::oxide_codec());
211 let config = options.apply(config, None)?;
212 let state = SessionStateBuilder::new()
213 .with_default_features()
214 .with_config(config)
215 .build();
216 let ctx = SessionContext::remote_with_state(scheduler_url, state).await?;
217 for udf in oxide_udfs() {
218 ctx.register_udf(udf.as_ref().clone());
219 }
220 Ok(Self {
221 ctx,
222 mode: SessionMode::Cluster {
223 scheduler_url: scheduler_url.to_owned(),
224 },
225 telemetry: TelemetryHub::new(),
226 })
227 }
228
229 pub fn mode(&self) -> &SessionMode {
231 &self.mode
232 }
233
234 pub fn ctx(&self) -> &SessionContext {
236 &self.ctx
237 }
238
239 pub fn telemetry(&self) -> &Arc<TelemetryHub> {
248 &self.telemetry
249 }
250
251 pub async fn sql(&self, query: &str) -> Result<DataFrame, EngineError> {
256 Ok(self.ctx.sql(query).await?)
257 }
258
259 pub async fn collect(&self, query: &str) -> Result<(SchemaRef, Vec<RecordBatch>), EngineError> {
272 let started = Instant::now();
273 let frame = self.sql(query).await?;
274 let schema = SchemaRef::from(frame.schema().clone());
275 let batches = frame.collect().await?;
276 let rows: usize = batches.iter().map(RecordBatch::num_rows).sum();
277 let operators = self.telemetry.snapshot().operators;
278 let fallback_batches: u64 = operators.iter().map(|o| o.fallback_batches).sum();
279 let accelerated = operators.iter().filter(|o| o.backend.is_gpu()).count();
280 tracing::info!(
281 mode = %self.mode,
282 rows,
283 batches = batches.len(),
284 elapsed_ms = started.elapsed().as_secs_f64() * 1_000.0,
285 gpu_operators = accelerated,
286 fallback_batches,
287 "query finished"
288 );
289 Ok((schema, batches))
290 }
291
292 pub async fn register_parquet(&self, name: &str, path: &str) -> Result<(), EngineError> {
294 register_parquet_table(&self.ctx, name, path).await
295 }
296
297 pub async fn explain(&self, query: &str) -> Result<String, EngineError> {
301 self.telemetry.clear_skips();
302 let plan = self.ctx.sql(query).await?.create_physical_plan().await?;
303 let mut text = displayable(plan.as_ref()).indent(true).to_string();
304 text.push_str(&self.placement_notes());
305 Ok(text)
306 }
307
308 fn placement_notes(&self) -> String {
318 let skips = self.telemetry.skips();
319 if skips.is_empty() {
320 return String::new();
321 }
322 let mut out = String::from("\nplacement notes (target ");
323 match &self.mode {
324 SessionMode::Embedded { target } => out.push_str(target.as_str()),
325 SessionMode::Cluster { .. } => out.push_str("on the scheduler"),
326 }
327 out.push_str("):\n");
328 for skip in skips {
329 out.push_str(&format!(" {}: {}\n", skip.node, skip.reason));
330 }
331 out
332 }
333}
334
335impl std::fmt::Debug for OxideSession {
336 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
337 f.debug_struct("OxideSession")
338 .field("mode", &self.mode)
339 .field("session_id", &self.ctx.session_id())
340 .finish_non_exhaustive()
341 }
342}
343
344#[cfg(test)]
345#[allow(clippy::unwrap_used, clippy::expect_used)]
346mod tests {
347 use super::*;
348
349 #[tokio::test]
350 async fn embedded_session_runs_sql_and_reports_mode() {
351 let session = OxideSession::local().unwrap();
352 assert!(matches!(session.mode(), SessionMode::Embedded { .. }));
353 let batches = session
354 .sql("SELECT 1 + 1 AS two")
355 .await
356 .unwrap()
357 .collect()
358 .await
359 .unwrap();
360 assert_eq!(batches.len(), 1);
361 assert_eq!(batches[0].num_rows(), 1);
362 let text = session.explain("SELECT 1 + 1 AS two").await.unwrap();
363 assert!(text.contains("ProjectionExec"), "{text}");
364 }
365
366 fn batch_size(session: &OxideSession) -> usize {
367 session.ctx().state().config().batch_size()
368 }
369
370 #[test]
374 fn the_batch_size_option_reaches_the_session_configuration() {
375 let session =
376 OxideSession::local_with_options(&SessionOptions::new().with_batch_size(7)).unwrap();
377 assert_eq!(batch_size(&session), 7);
378 }
379
380 #[test]
384 fn the_target_picks_the_default_batch_size_and_the_option_overrides_it() {
385 let gpu = OxideSession::local_with_target(BackendKind::Cuda).unwrap();
386 assert_eq!(batch_size(&gpu), oxidelake_storage::GPU_BATCH_SIZE);
387
388 let cpu = OxideSession::local_with_target(BackendKind::CpuSimd).unwrap();
389 assert_eq!(batch_size(&cpu), SessionConfig::new().batch_size());
390
391 let forced = OxideSession::local_with_options(
392 &SessionOptions::new()
393 .with_target(BackendKind::Cuda)
394 .with_batch_size(1_024),
395 )
396 .unwrap();
397 assert_eq!(batch_size(&forced), 1_024);
398 }
399
400 #[test]
401 fn a_zero_batch_size_is_refused() {
402 let err = OxideSession::local_with_options(&SessionOptions::new().with_batch_size(0))
403 .unwrap_err()
404 .to_string();
405 assert!(err.contains("at least 1 row"), "{err}");
406 }
407
408 #[test]
414 fn a_session_reports_real_capacities_and_an_honest_spill_flag() {
415 let session = OxideSession::local().unwrap();
416 let capacity = session.telemetry().capacity();
417 assert!(
418 !capacity.spill_on_query_path,
419 "no operator registers with a SpillManager in 0.2"
420 );
421 let SessionMode::Embedded { target } = session.mode() else {
422 panic!("local() is embedded");
423 };
424 let reported = if target.is_gpu() {
425 capacity.device_bytes
426 } else {
427 capacity.host_bytes
428 };
429 assert!(
432 reported.is_some_and(|bytes| bytes > 0),
433 "{target} reported no memory: {capacity:?}"
434 );
435 let absent = if target.is_gpu() {
438 capacity.host_bytes
439 } else {
440 capacity.device_bytes
441 };
442 assert_eq!(absent, None);
443 }
444
445 #[test]
448 fn the_default_options_are_the_plain_constructor() {
449 let plain = OxideSession::local().unwrap();
450 let built = OxideSession::local_with_options(&SessionOptions::default()).unwrap();
451 assert_eq!(plain.mode(), built.mode());
452 assert_eq!(batch_size(&plain), batch_size(&built));
453 }
454}