Skip to main content

oxidelake_runtime/
session.rs

1//! The user-facing session: one query API over embedded and cluster execution.
2
3use 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/// Where a session executes.
26#[derive(Debug, Clone, PartialEq, Eq)]
27#[non_exhaustive]
28pub enum SessionMode {
29    /// In-process DataFusion with the placement rule targeting the local backend.
30    Embedded {
31        /// The backend detected on this machine.
32        target: BackendKind,
33    },
34    /// A Ballista cluster reached through its scheduler URL (`df://host:port`).
35    Cluster {
36        /// The scheduler URL.
37        scheduler_url: String,
38    },
39}
40
41/// What the local backend says its memory tiers hold (#25).
42///
43/// A GPU backend's `memory_info` describes the device; the CPU backend's
44/// describes host RAM. Neither is a guess, and a backend that cannot answer
45/// leaves the field `None` rather than contributing a number the dashboard
46/// would draw a gauge against.
47///
48/// `spill_on_query_path` is `false` and stays false until an operator
49/// registers a batch with a `SpillManager`. Nothing does in 0.2: the hash
50/// join's build side is probed by every batch, so demoting it would trade a
51/// bounded memory claim for an unbounded one in time, and a spilling
52/// aggregate needs the streaming aggregate first. Saying so is the honest
53/// half of this release; see `docs/architecture.md`.
54fn 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    /// `embedded/cuda` or `cluster/df://host:port` — one field in a log line
78    /// rather than two, because the second is only meaningful given the first.
79    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/// Knobs a session is built with.
88///
89/// Every field has a default that matches the no-argument constructors, so
90/// `SessionOptions::default()` and [`OxideSession::local`] agree. The struct
91/// is `#[non_exhaustive]`: a knob added later is a minor release, not a
92/// broken build for anyone who used the builder methods.
93#[derive(Debug, Clone, Default, PartialEq, Eq)]
94#[non_exhaustive]
95pub struct SessionOptions {
96    /// Plan for this backend instead of the detected one (embedded only).
97    pub target: Option<BackendKind>,
98    /// Rows per record batch. `None` leaves DataFusion's default (8192), or
99    /// [`oxidelake_storage::GPU_BATCH_SIZE`] when the placement target is a GPU.
100    pub batch_size: Option<usize>,
101}
102
103impl SessionOptions {
104    /// Defaults: detected backend, default batch size.
105    pub fn new() -> Self {
106        Self::default()
107    }
108
109    /// Plans for `target` rather than the detected backend.
110    pub fn with_target(mut self, target: BackendKind) -> Self {
111        self.target = Some(target);
112        self
113    }
114
115    /// Sets the rows per record batch, overriding the CPU and GPU defaults.
116    pub fn with_batch_size(mut self, rows: usize) -> Self {
117        self.batch_size = Some(rows);
118        self
119    }
120
121    /// Applies the batch size to `config`, or the GPU default when the
122    /// placement target is a device and no size was asked for.
123    ///
124    /// A batch size of zero is rejected here rather than passed on: DataFusion
125    /// would take it and then produce no rows at all, which reads as an empty
126    /// result rather than as a bad flag.
127    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
143/// An OxideLake session.
144pub struct OxideSession {
145    ctx: SessionContext,
146    mode: SessionMode,
147    telemetry: Arc<TelemetryHub>,
148}
149
150impl OxideSession {
151    /// Creates an embedded session: Parquet pruning on, the local object store
152    /// registered, the SQL UDFs, and the placement rule targeting the detected
153    /// backend.
154    pub fn local() -> Result<Self, EngineError> {
155        Self::local_with_options(&SessionOptions::new())
156    }
157
158    /// An embedded session whose placement rule targets `target` instead of
159    /// the detected backend. Placement is a planning decision: operators still
160    /// select the real local backend at `execute()` time and fall back to the
161    /// CPU reference, so planning for an absent GPU is safe — it is exactly
162    /// what cluster executors do with the scheduler's plans.
163    pub fn local_with_target(target: BackendKind) -> Result<Self, EngineError> {
164        Self::local_with_options(&SessionOptions::new().with_target(target))
165    }
166
167    /// An embedded session built with `options`.
168    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    /// Connects to a Ballista scheduler (`df://host:port`). The session carries
195    /// OxideLake's plan codec so `Gpu*Exec` nodes survive the trip to executors,
196    /// and the SQL UDFs so queries plan client-side; placement itself happens
197    /// on the scheduler.
198    pub async fn connect(scheduler_url: &str) -> Result<Self, EngineError> {
199        Self::connect_with_options(scheduler_url, &SessionOptions::new()).await
200    }
201
202    /// Connects to a Ballista scheduler with `options`. `target` is ignored:
203    /// on a cluster the scheduler's `OXIDE_CLUSTER_BACKEND` decides placement,
204    /// so a client-side target would be a knob that quietly does nothing.
205    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    /// The execution mode.
230    pub fn mode(&self) -> &SessionMode {
231        &self.mode
232    }
233
234    /// The underlying DataFusion context.
235    pub fn ctx(&self) -> &SessionContext {
236        &self.ctx
237    }
238
239    /// The telemetry hub for this session.
240    ///
241    /// **Embedded sessions only.** A cluster session's operators run on
242    /// executors, in other processes; this hub is created for the shape of
243    /// the type and stays empty, so a snapshot of it is not "no work
244    /// happened" but "the work happened somewhere else" (#33). Each worker
245    /// reports into its own process-wide hub, which `oxide-worker
246    /// --metrics-port` exposes as Prometheus text.
247    pub fn telemetry(&self) -> &Arc<TelemetryHub> {
248        &self.telemetry
249    }
250
251    /// Plans a SQL statement into a lazily executed [`DataFrame`].
252    ///
253    /// Nothing runs here, so nothing is logged here: see [`Self::collect`]
254    /// for the one-line-per-query record.
255    pub async fn sql(&self, query: &str) -> Result<DataFrame, EngineError> {
256        Ok(self.ctx.sql(query).await?)
257    }
258
259    /// Runs `query` to completion and logs one `INFO` line describing it
260    /// (#33): the backend it was planned for, the rows it produced, how long
261    /// it took, and how many batches fell back to the CPU reference.
262    ///
263    /// This is the line an operator reads to answer "is the GPU being used
264    /// and how long did the query take" without attaching a dashboard. It is
265    /// on `collect` rather than on [`Self::sql`] because a `DataFrame` has
266    /// not run yet: a line logged at planning time could only report the
267    /// plan, and the interesting half is what the plan then did.
268    /// The schema comes back beside the batches because an empty result has
269    /// no batch to take it from, and a caller rendering CSV still has to
270    /// print the header.
271    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    /// Registers a Parquet file or directory as `name`.
293    pub async fn register_parquet(&self, name: &str, path: &str) -> Result<(), EngineError> {
294        register_parquet_table(&self.ctx, name, path).await
295    }
296
297    /// The indented physical plan for `query`, with placement tags in embedded
298    /// mode. In cluster mode this is the client-side plan (`DistributedQueryExec`);
299    /// the scheduler's plan is what carries the tags there.
300    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    /// The placement rule's reasons for leaving nodes on the CPU (#32).
309    ///
310    /// A node the rule skipped is an ordinary DataFusion operator in the
311    /// plan above, indistinguishable from one that was never eligible. The
312    /// reason used to live only in a `debug!` line, which is no use to
313    /// someone reading a plan, so it is printed under it.
314    ///
315    /// Empty in cluster mode: the rule runs on the scheduler there, and this
316    /// process only planned the `DistributedQueryExec` wrapper.
317    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    /// `--batch-size` has to reach DataFusion's configuration: a flag that is
371    /// parsed and then dropped would pass every test that only compares query
372    /// results, because batch boundaries do not change them.
373    #[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    /// Without an explicit size, a GPU-targeted session keeps the larger
381    /// default that amortises the host↔device round trip, and a CPU one keeps
382    /// DataFusion's. An explicit size overrides both.
383    #[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    /// The dashboard's memory gauges are drawn against what the backend says,
409    /// and the spill flag stays false until a query path registers a batch —
410    /// which nothing does in 0.2 (#25). Both halves matter: a capacity that
411    /// was a constant and a gauge that could never move made the dashboard
412    /// describe a bounded memory model the engine does not have.
413    #[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        // The number comes from the machine, so the assertion is on its
430        // shape: present, and not the 4 GiB constant the panel used to draw.
431        assert!(
432            reported.is_some_and(|bytes| bytes > 0),
433            "{target} reported no memory: {capacity:?}"
434        );
435        // The other tier has no backend to ask, and says so rather than
436        // contributing a number.
437        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    /// The no-argument constructors and `SessionOptions::default()` are the
446    /// same session, so the builder cannot drift away from the plain one.
447    #[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}