Skip to main content

lance_datafusion/
exec.rs

1// SPDX-License-Identifier: Apache-2.0
2// SPDX-FileCopyrightText: Copyright The Lance Authors
3
4//! Utilities for working with datafusion execution plans
5
6use std::{
7    collections::HashMap,
8    fmt::{self, Formatter},
9    num::NonZero,
10    sync::{Arc, Mutex, OnceLock},
11    time::Duration,
12};
13
14use chrono::{DateTime, Utc};
15
16use arrow_array::RecordBatch;
17use arrow_schema::Schema as ArrowSchema;
18use datafusion::{
19    catalog::streaming::StreamingTable,
20    dataframe::DataFrame,
21    execution::{
22        TaskContext,
23        context::{SessionConfig, SessionContext},
24        disk_manager::DiskManagerBuilder,
25        memory_pool::FairSpillPool,
26        runtime_env::RuntimeEnvBuilder,
27    },
28    physical_plan::{
29        DisplayAs, DisplayFormatType, ExecutionPlan, PlanProperties, SendableRecordBatchStream,
30        analyze::AnalyzeExec,
31        coalesce_partitions::CoalescePartitionsExec,
32        display::DisplayableExecutionPlan,
33        execution_plan::{Boundedness, CardinalityEffect, EmissionType},
34        metrics::MetricValue,
35        stream::RecordBatchStreamAdapter,
36        streaming::PartitionStream,
37    },
38};
39use datafusion::{execution::memory_pool::TrackConsumersPool, physical_plan::metrics::MetricType};
40use datafusion_common::{DataFusionError, Statistics};
41use datafusion_physical_expr::{EquivalenceProperties, Partitioning};
42
43use futures::{StreamExt, stream};
44use lance_arrow::SchemaExt;
45use lance_core::{
46    Error, Result,
47    utils::{
48        futures::FinallyStreamExt,
49        tracing::{EXECUTION_PLAN_RUN, StreamTracingExt, TRACE_EXECUTION},
50    },
51};
52use log::{debug, info, warn};
53use tracing::Span;
54
55use crate::udf::register_functions;
56use crate::{
57    chunker::StrictBatchSizeStream,
58    utils::{
59        BYTES_READ_METRIC, INDEX_COMPARISONS_METRIC, INDICES_LOADED_METRIC, IOPS_METRIC,
60        MetricsExt, PARTS_LOADED_METRIC, REQUESTS_METRIC,
61    },
62};
63
64/// An source execution node created from an existing stream
65///
66/// It can only be used once, and will return the stream.  After that the node
67/// is exhausted.
68///
69/// Note: the stream should be finite, otherwise we will report datafusion properties
70/// incorrectly.
71pub struct OneShotExec {
72    stream: Mutex<Option<SendableRecordBatchStream>>,
73    // We save off a copy of the schema to speed up formatting and so ExecutionPlan::schema & display_as
74    // can still function after exhausted
75    schema: Arc<ArrowSchema>,
76    properties: Arc<PlanProperties>,
77}
78
79impl OneShotExec {
80    /// Create a new instance from a given stream
81    pub fn new(stream: SendableRecordBatchStream) -> Self {
82        let schema = stream.schema();
83        Self {
84            stream: Mutex::new(Some(stream)),
85            schema: schema.clone(),
86            properties: Arc::new(PlanProperties::new(
87                EquivalenceProperties::new(schema),
88                Partitioning::RoundRobinBatch(1),
89                EmissionType::Incremental,
90                Boundedness::Bounded,
91            )),
92        }
93    }
94
95    pub fn from_batch(batch: RecordBatch) -> Self {
96        let schema = batch.schema();
97        let stream = Box::pin(RecordBatchStreamAdapter::new(
98            schema,
99            stream::iter(vec![Ok(batch)]),
100        ));
101        Self::new(stream)
102    }
103}
104
105impl std::fmt::Debug for OneShotExec {
106    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
107        let stream = self.stream.lock().unwrap();
108        f.debug_struct("OneShotExec")
109            .field("exhausted", &stream.is_none())
110            .field("schema", self.schema.as_ref())
111            .finish()
112    }
113}
114
115impl DisplayAs for OneShotExec {
116    fn fmt_as(
117        &self,
118        t: datafusion::physical_plan::DisplayFormatType,
119        f: &mut std::fmt::Formatter,
120    ) -> std::fmt::Result {
121        let stream = self.stream.lock().unwrap();
122        let exhausted = if stream.is_some() { "" } else { "EXHAUSTED" };
123        let columns = self
124            .schema
125            .field_names()
126            .iter()
127            .cloned()
128            .cloned()
129            .collect::<Vec<_>>();
130        match t {
131            DisplayFormatType::Default | DisplayFormatType::Verbose => {
132                write!(
133                    f,
134                    "OneShotStream: {}columns=[{}]",
135                    exhausted,
136                    columns.join(",")
137                )
138            }
139            DisplayFormatType::TreeRender => {
140                write!(
141                    f,
142                    "OneShotStream\nexhausted={}\ncolumns=[{}]",
143                    exhausted,
144                    columns.join(",")
145                )
146            }
147        }
148    }
149}
150
151impl ExecutionPlan for OneShotExec {
152    fn name(&self) -> &str {
153        "OneShotExec"
154    }
155
156    fn schema(&self) -> arrow_schema::SchemaRef {
157        self.schema.clone()
158    }
159
160    fn children(&self) -> Vec<&Arc<dyn ExecutionPlan>> {
161        vec![]
162    }
163
164    fn with_new_children(
165        self: Arc<Self>,
166        children: Vec<Arc<dyn ExecutionPlan>>,
167    ) -> datafusion_common::Result<Arc<dyn ExecutionPlan>> {
168        // OneShotExec has no children, so this should only be called with an empty vector
169        if !children.is_empty() {
170            return Err(datafusion_common::DataFusionError::Internal(
171                "OneShotExec does not support children".to_string(),
172            ));
173        }
174        Ok(self)
175    }
176
177    fn execute(
178        &self,
179        _partition: usize,
180        _context: Arc<datafusion::execution::TaskContext>,
181    ) -> datafusion_common::Result<SendableRecordBatchStream> {
182        let stream = self
183            .stream
184            .lock()
185            .map_err(|err| DataFusionError::Execution(err.to_string()))?
186            .take();
187        if let Some(stream) = stream {
188            Ok(stream)
189        } else {
190            Err(DataFusionError::Execution(
191                "OneShotExec has already been executed".to_string(),
192            ))
193        }
194    }
195
196    fn properties(&self) -> &Arc<datafusion::physical_plan::PlanProperties> {
197        &self.properties
198    }
199}
200
201struct TracedExec {
202    input: Arc<dyn ExecutionPlan>,
203    properties: Arc<PlanProperties>,
204    span: Span,
205}
206
207impl TracedExec {
208    pub fn new(input: Arc<dyn ExecutionPlan>, span: Span) -> Self {
209        Self {
210            properties: input.properties().clone(),
211            input,
212            span,
213        }
214    }
215}
216
217impl DisplayAs for TracedExec {
218    fn fmt_as(
219        &self,
220        t: datafusion::physical_plan::DisplayFormatType,
221        f: &mut std::fmt::Formatter,
222    ) -> std::fmt::Result {
223        match t {
224            DisplayFormatType::Default
225            | DisplayFormatType::Verbose
226            | DisplayFormatType::TreeRender => {
227                write!(f, "TracedExec")
228            }
229        }
230    }
231}
232
233impl std::fmt::Debug for TracedExec {
234    fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
235        write!(f, "TracedExec")
236    }
237}
238impl ExecutionPlan for TracedExec {
239    fn name(&self) -> &str {
240        "TracedExec"
241    }
242
243    fn properties(&self) -> &Arc<PlanProperties> {
244        &self.properties
245    }
246
247    fn children(&self) -> Vec<&Arc<dyn ExecutionPlan>> {
248        vec![&self.input]
249    }
250
251    fn with_new_children(
252        self: Arc<Self>,
253        children: Vec<Arc<dyn ExecutionPlan>>,
254    ) -> datafusion_common::Result<Arc<dyn ExecutionPlan>> {
255        Ok(Arc::new(Self {
256            input: children[0].clone(),
257            properties: self.properties.clone(),
258            span: self.span.clone(),
259        }))
260    }
261
262    fn execute(
263        &self,
264        partition: usize,
265        context: Arc<TaskContext>,
266    ) -> datafusion_common::Result<SendableRecordBatchStream> {
267        let _guard = self.span.enter();
268        let stream = self.input.execute(partition, context)?;
269        let schema = stream.schema();
270        let stream = stream.stream_in_span(self.span.clone());
271        Ok(Box::pin(RecordBatchStreamAdapter::new(schema, stream)))
272    }
273}
274
275/// Callback for reporting statistics after a scan
276pub type ExecutionStatsCallback = Arc<dyn Fn(&ExecutionSummaryCounts) + Send + Sync>;
277
278#[derive(Default, Clone)]
279pub struct LanceExecutionOptions {
280    pub use_spilling: bool,
281    pub mem_pool_size: Option<u64>,
282    pub max_temp_directory_size: Option<u64>,
283    pub batch_size: Option<usize>,
284    pub target_partition: Option<usize>,
285    pub execution_stats_callback: Option<ExecutionStatsCallback>,
286    pub skip_logging: bool,
287}
288
289impl std::fmt::Debug for LanceExecutionOptions {
290    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
291        f.debug_struct("LanceExecutionOptions")
292            .field("use_spilling", &self.use_spilling)
293            .field("mem_pool_size", &self.mem_pool_size)
294            .field("max_temp_directory_size", &self.max_temp_directory_size)
295            .field("batch_size", &self.batch_size)
296            .field("target_partition", &self.target_partition)
297            .field("skip_logging", &self.skip_logging)
298            .field(
299                "execution_stats_callback",
300                &self.execution_stats_callback.is_some(),
301            )
302            .finish()
303    }
304}
305
306const DEFAULT_LANCE_MEM_POOL_SIZE_PER_PARTITION: u64 = 150 * 1024 * 1024;
307const DEFAULT_LANCE_MAX_TEMP_DIRECTORY_SIZE: u64 = 100 * 1024 * 1024 * 1024; // 100GB
308
309impl LanceExecutionOptions {
310    pub fn mem_pool_size(&self) -> u64 {
311        let num_partitions = self.target_partition.unwrap_or(1) as u64;
312        self.mem_pool_size.unwrap_or_else(|| {
313            std::env::var("LANCE_MEM_POOL_SIZE")
314                .map(|s| match s.parse::<u64>() {
315                    Ok(v) => v,
316                    Err(e) => {
317                        warn!("Failed to parse LANCE_MEM_POOL_SIZE: {}, using default", e);
318                        DEFAULT_LANCE_MEM_POOL_SIZE_PER_PARTITION * num_partitions
319                    }
320                })
321                .unwrap_or(DEFAULT_LANCE_MEM_POOL_SIZE_PER_PARTITION * num_partitions)
322        })
323    }
324
325    pub fn max_temp_directory_size(&self) -> u64 {
326        self.max_temp_directory_size.unwrap_or_else(|| {
327            std::env::var("LANCE_MAX_TEMP_DIRECTORY_SIZE")
328                .map(|s| match s.parse::<u64>() {
329                    Ok(v) => v,
330                    Err(e) => {
331                        warn!(
332                            "Failed to parse LANCE_MAX_TEMP_DIRECTORY_SIZE: {}, using default",
333                            e
334                        );
335                        DEFAULT_LANCE_MAX_TEMP_DIRECTORY_SIZE
336                    }
337                })
338                .unwrap_or(DEFAULT_LANCE_MAX_TEMP_DIRECTORY_SIZE)
339        })
340    }
341
342    pub fn use_spilling(&self) -> bool {
343        if !self.use_spilling {
344            return false;
345        }
346        std::env::var("LANCE_BYPASS_SPILLING")
347            .map(|_| {
348                info!("Bypassing spilling because LANCE_BYPASS_SPILLING is set");
349                false
350            })
351            .unwrap_or(true)
352    }
353}
354
355pub fn new_session_context(options: &LanceExecutionOptions) -> SessionContext {
356    let mut session_config = SessionConfig::new();
357    let mut runtime_env_builder = RuntimeEnvBuilder::new();
358    if let Some(target_partition) = options.target_partition {
359        session_config = session_config.with_target_partitions(target_partition);
360    }
361    if options.use_spilling() {
362        // The default 10MB sort spill reservation seems to be too small for many common cases.
363        //
364        // There currently is no reasonable guidance provided by DataFusion for setting this value.
365        // We bump this to 40MB but try a smaller value if the mem pool is small.
366        let sort_spill_reservation_bytes =
367            (options.mem_pool_size() / 3).min(40 * 1024 * 1024) as usize;
368        session_config =
369            session_config.with_sort_spill_reservation_bytes(sort_spill_reservation_bytes);
370        let disk_manager_builder = DiskManagerBuilder::default()
371            .with_max_temp_directory_size(options.max_temp_directory_size());
372        runtime_env_builder = runtime_env_builder
373            .with_disk_manager_builder(disk_manager_builder)
374            .with_memory_pool(Arc::new(TrackConsumersPool::new(
375                FairSpillPool::new(options.mem_pool_size() as usize),
376                NonZero::try_from(16).unwrap(),
377            )));
378    }
379    let runtime_env = runtime_env_builder.build_arc().unwrap();
380
381    let ctx = SessionContext::new_with_config_rt(session_config, runtime_env);
382    register_functions(&ctx);
383
384    ctx
385}
386
387/// Cache key for session contexts based on resolved configuration values.
388#[derive(Clone, Debug, PartialEq, Eq, Hash)]
389struct SessionContextCacheKey {
390    mem_pool_size: u64,
391    max_temp_directory_size: u64,
392    target_partition: Option<usize>,
393    use_spilling: bool,
394}
395
396impl SessionContextCacheKey {
397    fn from_options(options: &LanceExecutionOptions) -> Self {
398        Self {
399            mem_pool_size: options.mem_pool_size(),
400            max_temp_directory_size: options.max_temp_directory_size(),
401            target_partition: options.target_partition,
402            use_spilling: options.use_spilling(),
403        }
404    }
405}
406
407struct CachedSessionContext {
408    context: SessionContext,
409    last_access: std::time::Instant,
410}
411
412fn get_session_cache() -> &'static Mutex<HashMap<SessionContextCacheKey, CachedSessionContext>> {
413    static SESSION_CACHE: OnceLock<Mutex<HashMap<SessionContextCacheKey, CachedSessionContext>>> =
414        OnceLock::new();
415    SESSION_CACHE.get_or_init(|| Mutex::new(HashMap::new()))
416}
417
418fn get_max_cache_size() -> usize {
419    const DEFAULT_CACHE_SIZE: usize = 4;
420    static MAX_CACHE_SIZE: OnceLock<usize> = OnceLock::new();
421    *MAX_CACHE_SIZE.get_or_init(|| {
422        std::env::var("LANCE_SESSION_CACHE_SIZE")
423            .ok()
424            .and_then(|v| v.parse().ok())
425            .unwrap_or(DEFAULT_CACHE_SIZE)
426    })
427}
428
429pub fn get_session_context(options: &LanceExecutionOptions) -> SessionContext {
430    let key = SessionContextCacheKey::from_options(options);
431    let mut cache = get_session_cache()
432        .lock()
433        .unwrap_or_else(|e| e.into_inner());
434
435    // If key exists, update access time and return
436    if let Some(entry) = cache.get_mut(&key) {
437        entry.last_access = std::time::Instant::now();
438        return entry.context.clone();
439    }
440
441    // Evict least recently used entry if cache is full
442    if cache.len() >= get_max_cache_size()
443        && let Some(lru_key) = cache
444            .iter()
445            .min_by_key(|(_, v)| v.last_access)
446            .map(|(k, _)| k.clone())
447    {
448        cache.remove(&lru_key);
449    }
450
451    let context = new_session_context(options);
452    cache.insert(
453        key,
454        CachedSessionContext {
455            context: context.clone(),
456            last_access: std::time::Instant::now(),
457        },
458    );
459    context
460}
461
462fn get_task_context(
463    session_ctx: &SessionContext,
464    options: &LanceExecutionOptions,
465) -> Arc<TaskContext> {
466    let mut state = session_ctx.state();
467    if let Some(batch_size) = options.batch_size.as_ref() {
468        state.config_mut().options_mut().execution.batch_size = *batch_size;
469    }
470
471    state.task_ctx()
472}
473
474#[derive(Default, Clone, Debug, PartialEq, Eq)]
475pub struct ExecutionSummaryCounts {
476    /// The number of I/O operations performed
477    pub iops: usize,
478    /// The number of requests made to the storage layer (may be larger or smaller than iops
479    /// depending on coalescing configuration)
480    pub requests: usize,
481    /// The number of bytes read during the execution of the plan
482    pub bytes_read: usize,
483    /// The number of top-level indices loaded
484    pub indices_loaded: usize,
485    /// The number of index partitions loaded
486    pub parts_loaded: usize,
487    /// The number of index comparisons performed (the exact meaning depends on the index type)
488    pub index_comparisons: usize,
489    /// Additional metrics for more detailed statistics.  These are subject to change in the future
490    /// and should only be used for debugging purposes.
491    pub all_counts: HashMap<String, usize>,
492    /// Additional time metrics for more detailed statistics, stored in nanoseconds.
493    /// These are subject to change in the future and should only be used for debugging purposes.
494    pub all_times: HashMap<String, usize>,
495}
496
497pub fn collect_execution_metrics(node: &dyn ExecutionPlan, counts: &mut ExecutionSummaryCounts) {
498    if let Some(metrics) = node.metrics() {
499        for (metric_name, count) in metrics.iter_counts() {
500            match metric_name.as_ref() {
501                IOPS_METRIC => counts.iops += count.value(),
502                REQUESTS_METRIC => counts.requests += count.value(),
503                BYTES_READ_METRIC => counts.bytes_read += count.value(),
504                INDICES_LOADED_METRIC => counts.indices_loaded += count.value(),
505                PARTS_LOADED_METRIC => counts.parts_loaded += count.value(),
506                INDEX_COMPARISONS_METRIC => counts.index_comparisons += count.value(),
507                _ => {
508                    let existing = counts
509                        .all_counts
510                        .entry(metric_name.as_ref().to_string())
511                        .or_insert(0);
512                    *existing += count.value();
513                }
514            }
515        }
516        for (metric_name, time) in metrics.iter_times() {
517            let existing = counts
518                .all_times
519                .entry(metric_name.as_ref().to_string())
520                .or_insert(0);
521            *existing += time.value();
522        }
523        // Include gauge-based I/O metrics (some nodes record I/O as gauges)
524        for (metric_name, gauge) in metrics.iter_gauges() {
525            match metric_name.as_ref() {
526                IOPS_METRIC => counts.iops += gauge.value(),
527                REQUESTS_METRIC => counts.requests += gauge.value(),
528                BYTES_READ_METRIC => counts.bytes_read += gauge.value(),
529                _ => {}
530            }
531        }
532    }
533    for child in node.children() {
534        collect_execution_metrics(child.as_ref(), counts);
535    }
536}
537
538fn report_plan_summary_metrics(plan: &dyn ExecutionPlan, options: &LanceExecutionOptions) {
539    let output_rows = plan
540        .metrics()
541        .map(|m| m.output_rows().unwrap_or(0))
542        .unwrap_or(0);
543    let mut counts = ExecutionSummaryCounts::default();
544    collect_execution_metrics(plan, &mut counts);
545    if !options.skip_logging {
546        tracing::info!(
547            target: TRACE_EXECUTION,
548            r#type = EXECUTION_PLAN_RUN,
549            plan_summary = display_plan_one_liner(plan),
550            output_rows,
551            iops = counts.iops,
552            requests = counts.requests,
553            bytes_read = counts.bytes_read,
554            indices_loaded = counts.indices_loaded,
555            parts_loaded = counts.parts_loaded,
556            index_comparisons = counts.index_comparisons,
557        );
558    }
559    if let Some(callback) = options.execution_stats_callback.as_ref() {
560        callback(&counts);
561    }
562}
563
564/// Create a one-line rough summary of the given execution plan.
565///
566/// The summary just shows the name of the operators in the plan. It omits any
567/// details such as parameters or schema information.
568///
569/// Example: `Projection(Take(CoalesceBatches(Filter(LanceScan))))`
570fn display_plan_one_liner(plan: &dyn ExecutionPlan) -> String {
571    let mut output = String::new();
572
573    display_plan_one_liner_impl(plan, &mut output);
574
575    output
576}
577
578fn display_plan_one_liner_impl(plan: &dyn ExecutionPlan, output: &mut String) {
579    // Remove the "Exec" suffix from the plan name if present for brevity
580    let name = plan.name().trim_end_matches("Exec");
581    output.push_str(name);
582
583    let children = plan.children();
584    if !children.is_empty() {
585        output.push('(');
586        for (i, child) in children.iter().enumerate() {
587            if i > 0 {
588                output.push(',');
589            }
590            display_plan_one_liner_impl(child.as_ref(), output);
591        }
592        output.push(')');
593    }
594}
595
596/// Executes a plan using default session & runtime configuration
597///
598/// Only executes a single partition.  Panics if the plan has more than one partition.
599pub fn execute_plan(
600    plan: Arc<dyn ExecutionPlan>,
601    options: LanceExecutionOptions,
602) -> Result<SendableRecordBatchStream> {
603    if !options.skip_logging {
604        debug!(
605            "Executing plan:\n{}",
606            DisplayableExecutionPlan::new(plan.as_ref()).indent(true)
607        );
608    }
609
610    let session_ctx = get_session_context(&options);
611
612    // Coalesce to a single partition if the optimizer left more than one.
613    // EnforceDistribution may remove RepartitionExec(1) nodes when the parent
614    // declares UnspecifiedDistribution, leaving multi-partition plans here.
615    let plan: Arc<dyn ExecutionPlan> = if plan.properties().partitioning.partition_count() == 1 {
616        plan
617    } else {
618        Arc::new(CoalescePartitionsExec::new(plan))
619    };
620
621    let stream = plan.execute(0, get_task_context(&session_ctx, &options))?;
622
623    let schema = stream.schema();
624    let stream = stream.finally(move || {
625        if !options.skip_logging || options.execution_stats_callback.is_some() {
626            report_plan_summary_metrics(plan.as_ref(), &options);
627        }
628    });
629    Ok(Box::pin(RecordBatchStreamAdapter::new(schema, stream)))
630}
631
632pub async fn analyze_plan(
633    plan: Arc<dyn ExecutionPlan>,
634    options: LanceExecutionOptions,
635) -> Result<String> {
636    // This is needed as AnalyzeExec launches a thread task per
637    // partition, and we want these to be connected to the parent span
638    let plan = Arc::new(TracedExec::new(plan, Span::current()));
639
640    let schema = plan.schema();
641    // TODO(tsaucer) I chose SUMMARY here but do we also want DEV?
642    let analyze = Arc::new(AnalyzeExec::new(
643        true,
644        true,
645        vec![MetricType::Summary],
646        None,
647        plan,
648        schema,
649    ));
650
651    let session_ctx = get_session_context(&options);
652    assert_eq!(analyze.properties().partitioning.partition_count(), 1);
653    let mut stream = analyze
654        .execute(0, get_task_context(&session_ctx, &options))
655        .map_err(|err| Error::io(format!("Failed to execute analyze plan: {}", err)))?;
656
657    // fully execute the plan
658    while (stream.next().await).is_some() {}
659
660    let result = format_plan(analyze);
661    Ok(result)
662}
663
664pub fn format_plan(plan: Arc<dyn ExecutionPlan>) -> String {
665    /// A visitor which calculates additional metrics for all the plans.
666    struct CalculateVisitor {
667        highest_index: usize,
668        index_to_elapsed: HashMap<usize, Duration>,
669    }
670
671    /// Result of calculating metrics for a subtree
672    struct SubtreeMetrics {
673        min_start: Option<DateTime<Utc>>,
674        max_end: Option<DateTime<Utc>>,
675    }
676
677    impl CalculateVisitor {
678        fn calculate_metrics(&mut self, plan: &Arc<dyn ExecutionPlan>) -> SubtreeMetrics {
679            self.highest_index += 1;
680            let plan_index = self.highest_index;
681
682            // Get timestamps for this node
683            let (mut min_start, mut max_end) = Self::node_timerange(plan);
684
685            // Accumulate from children
686            for child in plan.children() {
687                let child_metrics = self.calculate_metrics(child);
688                min_start = Self::min_option(min_start, child_metrics.min_start);
689                max_end = Self::max_option(max_end, child_metrics.max_end);
690            }
691
692            // Calculate wall clock duration for this subtree (only if we have timestamps)
693            let elapsed = match (min_start, max_end) {
694                (Some(start), Some(end)) => Some((end - start).to_std().unwrap_or_default()),
695                _ => None,
696            };
697
698            if let Some(e) = elapsed {
699                self.index_to_elapsed.insert(plan_index, e);
700            }
701
702            SubtreeMetrics { min_start, max_end }
703        }
704
705        fn node_timerange(
706            plan: &Arc<dyn ExecutionPlan>,
707        ) -> (Option<DateTime<Utc>>, Option<DateTime<Utc>>) {
708            let Some(metrics) = plan.metrics() else {
709                return (None, None);
710            };
711            let min_start = metrics
712                .iter()
713                .filter_map(|m| match m.value() {
714                    MetricValue::StartTimestamp(ts) => ts.value(),
715                    _ => None,
716                })
717                .min();
718            let max_end = metrics
719                .iter()
720                .filter_map(|m| match m.value() {
721                    MetricValue::EndTimestamp(ts) => ts.value(),
722                    _ => None,
723                })
724                .max();
725            (min_start, max_end)
726        }
727
728        fn min_option(a: Option<DateTime<Utc>>, b: Option<DateTime<Utc>>) -> Option<DateTime<Utc>> {
729            [a, b].into_iter().flatten().min()
730        }
731
732        fn max_option(a: Option<DateTime<Utc>>, b: Option<DateTime<Utc>>) -> Option<DateTime<Utc>> {
733            [a, b].into_iter().flatten().max()
734        }
735    }
736
737    /// A visitor which prints out all the plans.
738    struct PrintVisitor {
739        highest_index: usize,
740        indent: usize,
741    }
742    impl PrintVisitor {
743        fn write_output(
744            &mut self,
745            plan: &Arc<dyn ExecutionPlan>,
746            f: &mut Formatter,
747            calcs: &CalculateVisitor,
748        ) -> std::fmt::Result {
749            self.highest_index += 1;
750            write!(f, "{:indent$}", "", indent = self.indent * 2)?;
751
752            // Format the plan description
753            let displayable =
754                datafusion::physical_plan::display::DisplayableExecutionPlan::new(plan.as_ref());
755            let plan_str = displayable.one_line().to_string();
756            let plan_str = plan_str.trim();
757
758            // Write operator with elapsed time inserted after the name
759            match calcs.index_to_elapsed.get(&self.highest_index) {
760                Some(elapsed) => match plan_str.find(": ") {
761                    Some(i) => write!(
762                        f,
763                        "{}: elapsed={elapsed:?}, {}",
764                        &plan_str[..i],
765                        &plan_str[i + 2..]
766                    )?,
767                    None => write!(f, "{plan_str}, elapsed={elapsed:?}")?,
768                },
769                None => write!(f, "{plan_str}")?,
770            }
771
772            if let Some(metrics) = plan.metrics() {
773                let metrics = metrics
774                    .aggregate_by_name()
775                    .sorted_for_display()
776                    .timestamps_removed();
777
778                write!(f, ", metrics=[{metrics}]")?;
779            } else {
780                write!(f, ", metrics=[]")?;
781            }
782            writeln!(f)?;
783            self.indent += 1;
784            for child in plan.children() {
785                self.write_output(child, f, calcs)?;
786            }
787            self.indent -= 1;
788            std::fmt::Result::Ok(())
789        }
790    }
791    // A wrapper which prints out a plan.
792    struct PrintWrapper {
793        plan: Arc<dyn ExecutionPlan>,
794    }
795    impl fmt::Display for PrintWrapper {
796        fn fmt(&self, f: &mut Formatter) -> std::fmt::Result {
797            let mut calcs = CalculateVisitor {
798                highest_index: 0,
799                index_to_elapsed: HashMap::new(),
800            };
801            calcs.calculate_metrics(&self.plan);
802            let mut prints = PrintVisitor {
803                highest_index: 0,
804                indent: 0,
805            };
806            prints.write_output(&self.plan, f, &calcs)
807        }
808    }
809    let wrapper = PrintWrapper { plan };
810    format!("{}", wrapper)
811}
812
813pub trait SessionContextExt {
814    /// Creates a DataFrame for reading a stream of data
815    ///
816    /// This dataframe may only be queried once, future queries will fail
817    fn read_one_shot(
818        &self,
819        data: SendableRecordBatchStream,
820    ) -> datafusion::common::Result<DataFrame>;
821}
822
823pub struct OneShotPartitionStream {
824    data: Arc<Mutex<Option<SendableRecordBatchStream>>>,
825    schema: Arc<ArrowSchema>,
826}
827
828impl std::fmt::Debug for OneShotPartitionStream {
829    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
830        let data = self.data.lock().unwrap();
831        f.debug_struct("OneShotPartitionStream")
832            .field("exhausted", &data.is_none())
833            .field("schema", self.schema.as_ref())
834            .finish()
835    }
836}
837
838impl OneShotPartitionStream {
839    pub fn new(data: SendableRecordBatchStream) -> Self {
840        let schema = data.schema();
841        Self {
842            data: Arc::new(Mutex::new(Some(data))),
843            schema,
844        }
845    }
846}
847
848impl PartitionStream for OneShotPartitionStream {
849    fn schema(&self) -> &arrow_schema::SchemaRef {
850        &self.schema
851    }
852
853    fn execute(&self, _ctx: Arc<TaskContext>) -> SendableRecordBatchStream {
854        let mut stream = self.data.lock().unwrap();
855        stream
856            .take()
857            .expect("Attempt to consume a one shot dataframe multiple times")
858    }
859}
860
861impl SessionContextExt for SessionContext {
862    fn read_one_shot(
863        &self,
864        data: SendableRecordBatchStream,
865    ) -> datafusion::common::Result<DataFrame> {
866        let schema = data.schema();
867        let part_stream = Arc::new(OneShotPartitionStream::new(data));
868        let provider = StreamingTable::try_new(schema, vec![part_stream])?;
869        self.read_table(Arc::new(provider))
870    }
871}
872
873#[derive(Clone, Debug)]
874pub struct StrictBatchSizeExec {
875    input: Arc<dyn ExecutionPlan>,
876    batch_size: usize,
877}
878
879impl StrictBatchSizeExec {
880    pub fn new(input: Arc<dyn ExecutionPlan>, batch_size: usize) -> Self {
881        Self { input, batch_size }
882    }
883}
884
885impl DisplayAs for StrictBatchSizeExec {
886    fn fmt_as(
887        &self,
888        _t: datafusion::physical_plan::DisplayFormatType,
889        f: &mut std::fmt::Formatter,
890    ) -> std::fmt::Result {
891        write!(f, "StrictBatchSizeExec")
892    }
893}
894
895impl ExecutionPlan for StrictBatchSizeExec {
896    fn name(&self) -> &str {
897        "StrictBatchSizeExec"
898    }
899
900    fn properties(&self) -> &Arc<PlanProperties> {
901        self.input.properties()
902    }
903
904    fn children(&self) -> Vec<&Arc<dyn ExecutionPlan>> {
905        vec![&self.input]
906    }
907
908    fn with_new_children(
909        self: Arc<Self>,
910        children: Vec<Arc<dyn ExecutionPlan>>,
911    ) -> datafusion_common::Result<Arc<dyn ExecutionPlan>> {
912        Ok(Arc::new(Self {
913            input: children[0].clone(),
914            batch_size: self.batch_size,
915        }))
916    }
917
918    fn execute(
919        &self,
920        partition: usize,
921        context: Arc<TaskContext>,
922    ) -> datafusion_common::Result<SendableRecordBatchStream> {
923        let stream = self.input.execute(partition, context)?;
924        let schema = stream.schema();
925        let stream = StrictBatchSizeStream::new(stream, self.batch_size);
926        Ok(Box::pin(RecordBatchStreamAdapter::new(schema, stream)))
927    }
928
929    fn maintains_input_order(&self) -> Vec<bool> {
930        vec![true]
931    }
932
933    fn benefits_from_input_partitioning(&self) -> Vec<bool> {
934        vec![false]
935    }
936
937    fn partition_statistics(
938        &self,
939        partition: Option<usize>,
940    ) -> datafusion_common::Result<std::sync::Arc<Statistics>> {
941        self.input.partition_statistics(partition)
942    }
943
944    fn cardinality_effect(&self) -> CardinalityEffect {
945        CardinalityEffect::Equal
946    }
947
948    fn supports_limit_pushdown(&self) -> bool {
949        true
950    }
951}
952
953/// Exec node that rechunks batches so no output batch exceeds `max_bytes`.
954///
955/// # Why this exists
956///
957/// DataFusion's sort operator cannot handle batches larger than the memory
958/// pool size.  When upstream operators produce very large batches this can
959/// cause the sort to fail.  This node caps batch sizes
960/// *before* the sort so the operation succeeds.  The trade-off is a
961/// potentially expensive deep copy of the batch data — see below — but that
962/// is preferable to failing the operation entirely.  This workaround may
963/// become unnecessary if a fix is upstreamed to DataFusion.
964///
965/// # Deep copy
966///
967/// After slicing a RecordBatch, `get_array_memory_size` still reports the
968/// size of the *original* backing buffers, not the slice.  To get accurate
969/// sizes the slices must be deep-copied.  This is a last resort and can be
970/// expensive for large batches, but the deep copy is only performed when a
971/// batch actually needs to be sliced — batches that are already within the
972/// target range pass through at zero cost.
973///
974/// If a single row exceeds `max_bytes`, execution fails with an error.
975#[derive(Clone, Debug)]
976pub struct HardCapBatchSizeExec {
977    input: Arc<dyn ExecutionPlan>,
978    max_bytes: usize,
979}
980
981impl HardCapBatchSizeExec {
982    pub fn new(input: Arc<dyn ExecutionPlan>, max_bytes: usize) -> Self {
983        Self { input, max_bytes }
984    }
985}
986
987impl DisplayAs for HardCapBatchSizeExec {
988    fn fmt_as(
989        &self,
990        _t: datafusion::physical_plan::DisplayFormatType,
991        f: &mut std::fmt::Formatter,
992    ) -> std::fmt::Result {
993        write!(f, "HardCapBatchSizeExec(max_bytes={})", self.max_bytes)
994    }
995}
996
997impl ExecutionPlan for HardCapBatchSizeExec {
998    fn name(&self) -> &str {
999        "HardCapBatchSizeExec"
1000    }
1001
1002    fn properties(&self) -> &Arc<PlanProperties> {
1003        self.input.properties()
1004    }
1005
1006    fn children(&self) -> Vec<&Arc<dyn ExecutionPlan>> {
1007        vec![&self.input]
1008    }
1009
1010    fn with_new_children(
1011        self: Arc<Self>,
1012        children: Vec<Arc<dyn ExecutionPlan>>,
1013    ) -> datafusion_common::Result<Arc<dyn ExecutionPlan>> {
1014        Ok(Arc::new(Self {
1015            input: children[0].clone(),
1016            max_bytes: self.max_bytes,
1017        }))
1018    }
1019
1020    fn execute(
1021        &self,
1022        partition: usize,
1023        context: Arc<TaskContext>,
1024    ) -> datafusion_common::Result<SendableRecordBatchStream> {
1025        let stream = self.input.execute(partition, context)?;
1026        let schema = stream.schema();
1027        let max_bytes = self.max_bytes;
1028        let rechunked = lance_arrow::stream::rechunk_stream_by_size_deep_copy(
1029            stream,
1030            schema.clone(),
1031            0,
1032            max_bytes,
1033        );
1034        // Check that no single-row batch exceeds the limit.
1035        let validated = rechunked.map(move |result| {
1036            let batch = result?;
1037            if batch.num_rows() == 1 && batch.get_array_memory_size() > max_bytes {
1038                return Err(DataFusionError::External(Box::new(Error::invalid_input(
1039                    format!(
1040                        "a single row is {} bytes which exceeds the maximum allowed batch \
1041                         size of {} bytes",
1042                        batch.get_array_memory_size(),
1043                        max_bytes,
1044                    ),
1045                ))));
1046            }
1047            Ok(batch)
1048        });
1049        Ok(Box::pin(RecordBatchStreamAdapter::new(schema, validated)))
1050    }
1051
1052    fn maintains_input_order(&self) -> Vec<bool> {
1053        vec![true]
1054    }
1055
1056    fn benefits_from_input_partitioning(&self) -> Vec<bool> {
1057        vec![false]
1058    }
1059
1060    fn partition_statistics(
1061        &self,
1062        partition: Option<usize>,
1063    ) -> datafusion_common::Result<std::sync::Arc<Statistics>> {
1064        self.input.partition_statistics(partition)
1065    }
1066
1067    fn cardinality_effect(&self) -> CardinalityEffect {
1068        CardinalityEffect::Equal
1069    }
1070
1071    fn supports_limit_pushdown(&self) -> bool {
1072        true
1073    }
1074}
1075
1076#[cfg(test)]
1077mod tests {
1078    use super::*;
1079
1080    // Serialize cache tests since they share global state
1081    static CACHE_TEST_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
1082
1083    #[test]
1084    fn test_session_context_cache() {
1085        let _lock = CACHE_TEST_LOCK.lock().unwrap();
1086        let cache = get_session_cache();
1087
1088        // Clear any existing entries from other tests
1089        cache.lock().unwrap().clear();
1090
1091        // Create first session with default options
1092        let opts1 = LanceExecutionOptions::default();
1093        let _ctx1 = get_session_context(&opts1);
1094
1095        {
1096            let cache_guard = cache.lock().unwrap();
1097            assert_eq!(cache_guard.len(), 1);
1098        }
1099
1100        // Same options should reuse cached session (no new entry)
1101        let _ctx1_again = get_session_context(&opts1);
1102        {
1103            let cache_guard = cache.lock().unwrap();
1104            assert_eq!(cache_guard.len(), 1);
1105        }
1106
1107        // Different options should create new entry
1108        let opts2 = LanceExecutionOptions {
1109            use_spilling: true,
1110            ..Default::default()
1111        };
1112        let _ctx2 = get_session_context(&opts2);
1113        {
1114            let cache_guard = cache.lock().unwrap();
1115            assert_eq!(cache_guard.len(), 2);
1116        }
1117    }
1118
1119    #[test]
1120    fn test_session_context_cache_lru_eviction() {
1121        let _lock = CACHE_TEST_LOCK.lock().unwrap();
1122        let cache = get_session_cache();
1123
1124        // Clear any existing entries from other tests
1125        cache.lock().unwrap().clear();
1126
1127        // Create 4 different configurations to fill the cache
1128        let configs: Vec<LanceExecutionOptions> = (0..4)
1129            .map(|i| LanceExecutionOptions {
1130                mem_pool_size: Some((i + 1) as u64 * 1024 * 1024),
1131                ..Default::default()
1132            })
1133            .collect();
1134
1135        for config in &configs {
1136            let _ctx = get_session_context(config);
1137        }
1138
1139        {
1140            let cache_guard = cache.lock().unwrap();
1141            assert_eq!(cache_guard.len(), 4);
1142        }
1143
1144        // Access config[0] to make it more recently used than config[1]
1145        // (config[0] was inserted first, so without this access it would be evicted)
1146        std::thread::sleep(std::time::Duration::from_millis(1));
1147        let _ctx = get_session_context(&configs[0]);
1148
1149        // Add a 5th configuration - should evict config[1] (now least recently used)
1150        let opts5 = LanceExecutionOptions {
1151            mem_pool_size: Some(5 * 1024 * 1024),
1152            ..Default::default()
1153        };
1154        let _ctx5 = get_session_context(&opts5);
1155
1156        {
1157            let cache_guard = cache.lock().unwrap();
1158            assert_eq!(cache_guard.len(), 4);
1159
1160            // config[0] should still be present (was accessed recently)
1161            let key0 = SessionContextCacheKey::from_options(&configs[0]);
1162            assert!(
1163                cache_guard.contains_key(&key0),
1164                "config[0] should still be cached after recent access"
1165            );
1166
1167            // config[1] should be evicted (was least recently used)
1168            let key1 = SessionContextCacheKey::from_options(&configs[1]);
1169            assert!(
1170                !cache_guard.contains_key(&key1),
1171                "config[1] should have been evicted"
1172            );
1173
1174            // New config should be present
1175            let key5 = SessionContextCacheKey::from_options(&opts5);
1176            assert!(
1177                cache_guard.contains_key(&key5),
1178                "new config should be cached"
1179            );
1180        }
1181    }
1182
1183    #[test]
1184    fn test_mem_pool_size_scales_with_partitions() {
1185        let default_per_partition = DEFAULT_LANCE_MEM_POOL_SIZE_PER_PARTITION;
1186
1187        // No partitions specified → defaults to 1 partition
1188        let opts = LanceExecutionOptions::default();
1189        assert_eq!(opts.mem_pool_size(), default_per_partition);
1190
1191        // 4 partitions → 4x the per-partition size
1192        let opts = LanceExecutionOptions {
1193            target_partition: Some(4),
1194            ..Default::default()
1195        };
1196        assert_eq!(opts.mem_pool_size(), default_per_partition * 4);
1197
1198        // 8 partitions → 8x the per-partition size
1199        let opts = LanceExecutionOptions {
1200            target_partition: Some(8),
1201            ..Default::default()
1202        };
1203        assert_eq!(opts.mem_pool_size(), default_per_partition * 8);
1204
1205        // Explicit mem_pool_size is not scaled
1206        let opts = LanceExecutionOptions {
1207            mem_pool_size: Some(50 * 1024 * 1024),
1208            target_partition: Some(8),
1209            ..Default::default()
1210        };
1211        assert_eq!(opts.mem_pool_size(), 50 * 1024 * 1024);
1212    }
1213}