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::{TableProvider, 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, ExecutionPlanProperties, PlanProperties,
30        SendableRecordBatchStream,
31        analyze::AnalyzeExec,
32        coalesce_partitions::CoalescePartitionsExec,
33        display::DisplayableExecutionPlan,
34        execution_plan::{Boundedness, CardinalityEffect, EmissionType},
35        metrics::MetricValue,
36        sorts::sort_preserving_merge::SortPreservingMergeExec,
37        stream::RecordBatchStreamAdapter,
38        streaming::PartitionStream,
39    },
40};
41use datafusion::{execution::memory_pool::TrackConsumersPool, physical_plan::metrics::MetricType};
42use datafusion_common::{DataFusionError, Statistics};
43use datafusion_physical_expr::{EquivalenceProperties, Partitioning};
44
45use futures::{StreamExt, stream};
46use lance_arrow::SchemaExt;
47use lance_core::{
48    Error, Result,
49    utils::{
50        futures::FinallyStreamExt,
51        tracing::{EXECUTION_PLAN_RUN, StreamTracingExt, TRACE_EXECUTION},
52    },
53};
54use log::{debug, info, warn};
55use tracing::Span;
56
57use crate::udf::register_functions;
58use crate::{
59    chunker::StrictBatchSizeStream,
60    utils::{
61        BYTES_READ_METRIC, INDEX_CACHE_HITS_METRIC, INDEX_CACHE_MISSES_METRIC,
62        INDEX_COMPARISONS_METRIC, INDICES_LOADED_METRIC, IOPS_METRIC, MetricsExt,
63        PARTS_LOADED_METRIC, REQUESTS_METRIC,
64    },
65};
66
67/// An source execution node created from an existing stream
68///
69/// It can only be used once, and will return the stream.  After that the node
70/// is exhausted.
71///
72/// Note: the stream should be finite, otherwise we will report datafusion properties
73/// incorrectly.
74pub struct OneShotExec {
75    stream: Mutex<Option<SendableRecordBatchStream>>,
76    // We save off a copy of the schema to speed up formatting and so ExecutionPlan::schema & display_as
77    // can still function after exhausted
78    schema: Arc<ArrowSchema>,
79    properties: Arc<PlanProperties>,
80}
81
82impl OneShotExec {
83    /// Create a new instance from a given stream
84    pub fn new(stream: SendableRecordBatchStream) -> Self {
85        let schema = stream.schema();
86        Self {
87            stream: Mutex::new(Some(stream)),
88            schema: schema.clone(),
89            properties: Arc::new(PlanProperties::new(
90                EquivalenceProperties::new(schema),
91                Partitioning::RoundRobinBatch(1),
92                EmissionType::Incremental,
93                Boundedness::Bounded,
94            )),
95        }
96    }
97
98    pub fn from_batch(batch: RecordBatch) -> Self {
99        let schema = batch.schema();
100        let stream = Box::pin(RecordBatchStreamAdapter::new(
101            schema,
102            stream::iter(vec![Ok(batch)]),
103        ));
104        Self::new(stream)
105    }
106}
107
108impl std::fmt::Debug for OneShotExec {
109    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
110        let stream = self.stream.lock().unwrap();
111        f.debug_struct("OneShotExec")
112            .field("exhausted", &stream.is_none())
113            .field("schema", self.schema.as_ref())
114            .finish()
115    }
116}
117
118impl DisplayAs for OneShotExec {
119    fn fmt_as(
120        &self,
121        t: datafusion::physical_plan::DisplayFormatType,
122        f: &mut std::fmt::Formatter,
123    ) -> std::fmt::Result {
124        let stream = self.stream.lock().unwrap();
125        let exhausted = if stream.is_some() { "" } else { "EXHAUSTED" };
126        let columns = self
127            .schema
128            .field_names()
129            .iter()
130            .cloned()
131            .cloned()
132            .collect::<Vec<_>>();
133        match t {
134            DisplayFormatType::Default | DisplayFormatType::Verbose => {
135                write!(
136                    f,
137                    "OneShotStream: {}columns=[{}]",
138                    exhausted,
139                    columns.join(",")
140                )
141            }
142            DisplayFormatType::TreeRender => {
143                write!(
144                    f,
145                    "OneShotStream\nexhausted={}\ncolumns=[{}]",
146                    exhausted,
147                    columns.join(",")
148                )
149            }
150        }
151    }
152}
153
154impl ExecutionPlan for OneShotExec {
155    fn name(&self) -> &str {
156        "OneShotExec"
157    }
158
159    fn schema(&self) -> arrow_schema::SchemaRef {
160        self.schema.clone()
161    }
162
163    fn children(&self) -> Vec<&Arc<dyn ExecutionPlan>> {
164        vec![]
165    }
166
167    fn with_new_children(
168        self: Arc<Self>,
169        children: Vec<Arc<dyn ExecutionPlan>>,
170    ) -> datafusion_common::Result<Arc<dyn ExecutionPlan>> {
171        // OneShotExec has no children, so this should only be called with an empty vector
172        if !children.is_empty() {
173            return Err(datafusion_common::DataFusionError::Internal(
174                "OneShotExec does not support children".to_string(),
175            ));
176        }
177        Ok(self)
178    }
179
180    fn execute(
181        &self,
182        _partition: usize,
183        _context: Arc<datafusion::execution::TaskContext>,
184    ) -> datafusion_common::Result<SendableRecordBatchStream> {
185        let stream = self
186            .stream
187            .lock()
188            .map_err(|err| DataFusionError::Execution(err.to_string()))?
189            .take();
190        if let Some(stream) = stream {
191            Ok(stream)
192        } else {
193            Err(DataFusionError::Execution(
194                "OneShotExec has already been executed".to_string(),
195            ))
196        }
197    }
198
199    fn properties(&self) -> &Arc<datafusion::physical_plan::PlanProperties> {
200        &self.properties
201    }
202}
203
204struct TracedExec {
205    input: Arc<dyn ExecutionPlan>,
206    properties: Arc<PlanProperties>,
207    span: Span,
208}
209
210impl TracedExec {
211    pub fn new(input: Arc<dyn ExecutionPlan>, span: Span) -> Self {
212        Self {
213            properties: input.properties().clone(),
214            input,
215            span,
216        }
217    }
218}
219
220impl DisplayAs for TracedExec {
221    fn fmt_as(
222        &self,
223        t: datafusion::physical_plan::DisplayFormatType,
224        f: &mut std::fmt::Formatter,
225    ) -> std::fmt::Result {
226        match t {
227            DisplayFormatType::Default
228            | DisplayFormatType::Verbose
229            | DisplayFormatType::TreeRender => {
230                write!(f, "TracedExec")
231            }
232        }
233    }
234}
235
236impl std::fmt::Debug for TracedExec {
237    fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
238        write!(f, "TracedExec")
239    }
240}
241impl ExecutionPlan for TracedExec {
242    fn name(&self) -> &str {
243        "TracedExec"
244    }
245
246    fn properties(&self) -> &Arc<PlanProperties> {
247        &self.properties
248    }
249
250    fn children(&self) -> Vec<&Arc<dyn ExecutionPlan>> {
251        vec![&self.input]
252    }
253
254    fn with_new_children(
255        self: Arc<Self>,
256        children: Vec<Arc<dyn ExecutionPlan>>,
257    ) -> datafusion_common::Result<Arc<dyn ExecutionPlan>> {
258        Ok(Arc::new(Self {
259            input: children[0].clone(),
260            properties: self.properties.clone(),
261            span: self.span.clone(),
262        }))
263    }
264
265    fn execute(
266        &self,
267        partition: usize,
268        context: Arc<TaskContext>,
269    ) -> datafusion_common::Result<SendableRecordBatchStream> {
270        let _guard = self.span.enter();
271        let stream = self.input.execute(partition, context)?;
272        let schema = stream.schema();
273        let stream = stream.stream_in_span(self.span.clone());
274        Ok(Box::pin(RecordBatchStreamAdapter::new(schema, stream)))
275    }
276}
277
278/// Callback for reporting statistics after a scan
279pub type ExecutionStatsCallback = Arc<dyn Fn(&ExecutionSummaryCounts) + Send + Sync>;
280
281#[derive(Default, Clone)]
282pub struct LanceExecutionOptions {
283    pub use_spilling: bool,
284    pub mem_pool_size: Option<u64>,
285    pub max_temp_directory_size: Option<u64>,
286    pub batch_size: Option<usize>,
287    pub target_partition: Option<usize>,
288    pub execution_stats_callback: Option<ExecutionStatsCallback>,
289    pub skip_logging: bool,
290}
291
292impl std::fmt::Debug for LanceExecutionOptions {
293    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
294        f.debug_struct("LanceExecutionOptions")
295            .field("use_spilling", &self.use_spilling)
296            .field("mem_pool_size", &self.mem_pool_size)
297            .field("max_temp_directory_size", &self.max_temp_directory_size)
298            .field("batch_size", &self.batch_size)
299            .field("target_partition", &self.target_partition)
300            .field("skip_logging", &self.skip_logging)
301            .field(
302                "execution_stats_callback",
303                &self.execution_stats_callback.is_some(),
304            )
305            .finish()
306    }
307}
308
309const DEFAULT_LANCE_MEM_POOL_SIZE_PER_PARTITION: u64 = 150 * 1024 * 1024;
310const DEFAULT_LANCE_MAX_TEMP_DIRECTORY_SIZE: u64 = 100 * 1024 * 1024 * 1024; // 100GB
311
312impl LanceExecutionOptions {
313    pub fn mem_pool_size(&self) -> u64 {
314        let num_partitions = self.target_partition.unwrap_or(1) as u64;
315        self.mem_pool_size.unwrap_or_else(|| {
316            std::env::var("LANCE_MEM_POOL_SIZE")
317                .map(|s| match s.parse::<u64>() {
318                    Ok(v) => v,
319                    Err(e) => {
320                        warn!("Failed to parse LANCE_MEM_POOL_SIZE: {}, using default", e);
321                        DEFAULT_LANCE_MEM_POOL_SIZE_PER_PARTITION * num_partitions
322                    }
323                })
324                .unwrap_or(DEFAULT_LANCE_MEM_POOL_SIZE_PER_PARTITION * num_partitions)
325        })
326    }
327
328    pub fn max_temp_directory_size(&self) -> u64 {
329        self.max_temp_directory_size.unwrap_or_else(|| {
330            std::env::var("LANCE_MAX_TEMP_DIRECTORY_SIZE")
331                .map(|s| match s.parse::<u64>() {
332                    Ok(v) => v,
333                    Err(e) => {
334                        warn!(
335                            "Failed to parse LANCE_MAX_TEMP_DIRECTORY_SIZE: {}, using default",
336                            e
337                        );
338                        DEFAULT_LANCE_MAX_TEMP_DIRECTORY_SIZE
339                    }
340                })
341                .unwrap_or(DEFAULT_LANCE_MAX_TEMP_DIRECTORY_SIZE)
342        })
343    }
344
345    pub fn use_spilling(&self) -> bool {
346        if !self.use_spilling {
347            return false;
348        }
349        std::env::var("LANCE_BYPASS_SPILLING")
350            .map(|_| {
351                info!("Bypassing spilling because LANCE_BYPASS_SPILLING is set");
352                false
353            })
354            .unwrap_or(true)
355    }
356}
357
358pub fn new_session_context(options: &LanceExecutionOptions) -> SessionContext {
359    let mut session_config = SessionConfig::new();
360    let mut runtime_env_builder = RuntimeEnvBuilder::new();
361    if let Some(target_partition) = options.target_partition {
362        session_config = session_config.with_target_partitions(target_partition);
363    }
364    if options.use_spilling() {
365        // The default 10MB sort spill reservation seems to be too small for many common cases.
366        //
367        // There currently is no reasonable guidance provided by DataFusion for setting this value.
368        // We bump this to 40MB but try a smaller value if the mem pool is small.
369        let sort_spill_reservation_bytes =
370            (options.mem_pool_size() / 3).min(40 * 1024 * 1024) as usize;
371        session_config =
372            session_config.with_sort_spill_reservation_bytes(sort_spill_reservation_bytes);
373        let disk_manager_builder = DiskManagerBuilder::default()
374            .with_max_temp_directory_size(options.max_temp_directory_size());
375        runtime_env_builder = runtime_env_builder
376            .with_disk_manager_builder(disk_manager_builder)
377            .with_memory_pool(Arc::new(TrackConsumersPool::new(
378                FairSpillPool::new(options.mem_pool_size() as usize),
379                NonZero::try_from(16).unwrap(),
380            )));
381    }
382    let runtime_env = runtime_env_builder.build_arc().unwrap();
383
384    let ctx = SessionContext::new_with_config_rt(session_config, runtime_env);
385    register_functions(&ctx);
386
387    ctx
388}
389
390/// Cache key for session contexts based on resolved configuration values.
391#[derive(Clone, Debug, PartialEq, Eq, Hash)]
392struct SessionContextCacheKey {
393    mem_pool_size: u64,
394    max_temp_directory_size: u64,
395    target_partition: Option<usize>,
396    use_spilling: bool,
397}
398
399impl SessionContextCacheKey {
400    fn from_options(options: &LanceExecutionOptions) -> Self {
401        Self {
402            mem_pool_size: options.mem_pool_size(),
403            max_temp_directory_size: options.max_temp_directory_size(),
404            target_partition: options.target_partition,
405            use_spilling: options.use_spilling(),
406        }
407    }
408}
409
410struct CachedSessionContext {
411    context: SessionContext,
412    last_access: std::time::Instant,
413}
414
415fn get_session_cache() -> &'static Mutex<HashMap<SessionContextCacheKey, CachedSessionContext>> {
416    static SESSION_CACHE: OnceLock<Mutex<HashMap<SessionContextCacheKey, CachedSessionContext>>> =
417        OnceLock::new();
418    SESSION_CACHE.get_or_init(|| Mutex::new(HashMap::new()))
419}
420
421fn get_max_cache_size() -> usize {
422    const DEFAULT_CACHE_SIZE: usize = 4;
423    static MAX_CACHE_SIZE: OnceLock<usize> = OnceLock::new();
424    *MAX_CACHE_SIZE.get_or_init(|| {
425        std::env::var("LANCE_SESSION_CACHE_SIZE")
426            .ok()
427            .and_then(|v| v.parse().ok())
428            .unwrap_or(DEFAULT_CACHE_SIZE)
429    })
430}
431
432pub fn get_session_context(options: &LanceExecutionOptions) -> SessionContext {
433    let key = SessionContextCacheKey::from_options(options);
434    let mut cache = get_session_cache()
435        .lock()
436        .unwrap_or_else(|e| e.into_inner());
437
438    // If key exists, update access time and return
439    if let Some(entry) = cache.get_mut(&key) {
440        entry.last_access = std::time::Instant::now();
441        return entry.context.clone();
442    }
443
444    // Evict least recently used entry if cache is full
445    if cache.len() >= get_max_cache_size()
446        && let Some(lru_key) = cache
447            .iter()
448            .min_by_key(|(_, v)| v.last_access)
449            .map(|(k, _)| k.clone())
450    {
451        cache.remove(&lru_key);
452    }
453
454    let context = new_session_context(options);
455    cache.insert(
456        key,
457        CachedSessionContext {
458            context: context.clone(),
459            last_access: std::time::Instant::now(),
460        },
461    );
462    context
463}
464
465fn get_task_context(
466    session_ctx: &SessionContext,
467    options: &LanceExecutionOptions,
468) -> Arc<TaskContext> {
469    let mut state = session_ctx.state();
470    if let Some(batch_size) = options.batch_size.as_ref() {
471        state.config_mut().options_mut().execution.batch_size = *batch_size;
472    }
473
474    state.task_ctx()
475}
476
477#[derive(Default, Clone, Debug, PartialEq, Eq)]
478pub struct ExecutionSummaryCounts {
479    /// The number of I/O operations performed
480    pub iops: usize,
481    /// The number of requests made to the storage layer (may be larger or smaller than iops
482    /// depending on coalescing configuration)
483    pub requests: usize,
484    /// The number of bytes read during the execution of the plan
485    pub bytes_read: usize,
486    /// The number of top-level indices loaded
487    pub indices_loaded: usize,
488    /// The number of index partitions loaded
489    pub parts_loaded: usize,
490    /// The number of index comparisons performed (the exact meaning depends on the index type)
491    pub index_comparisons: usize,
492    /// Additional metrics for more detailed statistics.  These are subject to change in the future
493    /// and should only be used for debugging purposes.
494    ///
495    /// Newer metrics (e.g. [`INDEX_CACHE_HITS_METRIC`], [`INDEX_CACHE_MISSES_METRIC`]) are added
496    /// here rather than as `pub` fields, so this struct stays backwards compatible for callers
497    /// that construct or destructure it. Prefer the typed accessors below.
498    pub all_counts: HashMap<String, usize>,
499    /// Additional time metrics for more detailed statistics, stored in nanoseconds.
500    /// These are subject to change in the future and should only be used for debugging purposes.
501    pub all_times: HashMap<String, usize>,
502}
503
504impl ExecutionSummaryCounts {
505    /// Number of index cache page lookups where the loader was not executed
506    /// (per-page granularity).
507    ///
508    /// A "hit" is any page-level lookup at an instrumented cache boundary that
509    /// did not run the loader on this call. That covers both a true cache hit
510    /// on an already-populated entry and a coalesced concurrent load where an
511    /// in-flight loader started by a different caller produced the value.
512    ///
513    /// Instrumented boundaries in this release:
514    /// BTree page, IVF partition (v2, `write_cache=true` scan path), inverted
515    /// posting list (grouped and per-token), inverted per-token metadata
516    /// (`PostingMetadataKey`), inverted phrase positions (`PositionKey`),
517    /// bitmap posting (Equals / Range / IsIn), ngram posting, and rtree page
518    /// / null slot.
519    ///
520    /// Caveats:
521    /// * IVF v2 streaming scans and legacy v1 IVF partitions run
522    ///   `load_partition` with `write_cache=false`. Those loads always execute
523    ///   the loader and never write the result back, so they are reported as a
524    ///   miss on every call. See [`Self::index_cache_hit_ratio`].
525    /// * A cold posting-list lookup on the grouped inverted layout can record
526    ///   up to two misses (posting-list group + per-token metadata) for a
527    ///   single term.
528    ///
529    /// Other index cache boundaries such as HNSW graph pages and quantizer
530    /// codebooks are not yet instrumented; a scan that only touches those
531    /// paths returns `0` here.
532    pub fn index_cache_hits(&self) -> usize {
533        self.all_counts
534            .get(INDEX_CACHE_HITS_METRIC)
535            .copied()
536            .unwrap_or(0)
537    }
538
539    /// Number of index cache page lookups that had to execute the loader
540    /// (per-page granularity).
541    ///
542    /// A "miss" is any page-level lookup at an instrumented cache boundary
543    /// where the loader ran, i.e. the page was not resident and had to be
544    /// materialised (typically from storage). See
545    /// [`Self::index_cache_hits`] for the paired counter and the list of
546    /// instrumented boundaries.
547    pub fn index_cache_misses(&self) -> usize {
548        self.all_counts
549            .get(INDEX_CACHE_MISSES_METRIC)
550            .copied()
551            .unwrap_or(0)
552    }
553
554    /// Ratio of index cache hits to total lookups. Returns `0.0` when no lookups
555    /// were recorded in this scan.
556    ///
557    /// This ratio only reflects paths that write their result back to the
558    /// index cache. Streaming scans (IVF v2 `write_cache=false` and legacy v1
559    /// IVF `load_partition_stream`) intentionally bypass the cache and are
560    /// counted as misses on every call, so a workload dominated by streaming
561    /// vector scans will report a hit ratio near `0.0` regardless of cache
562    /// size.
563    pub fn index_cache_hit_ratio(&self) -> f32 {
564        // Widen to u128 before summing so a pathological (hits + misses)
565        // overflow can't panic in debug builds nor wrap in release builds.
566        let hits = self.index_cache_hits() as u128;
567        let total = hits + self.index_cache_misses() as u128;
568        if total == 0 {
569            0.0
570        } else {
571            hits as f32 / total as f32
572        }
573    }
574}
575
576pub fn collect_execution_metrics(node: &dyn ExecutionPlan, counts: &mut ExecutionSummaryCounts) {
577    if let Some(metrics) = node.metrics() {
578        for (metric_name, count) in metrics.iter_counts() {
579            match metric_name.as_ref() {
580                IOPS_METRIC => counts.iops += count.value(),
581                REQUESTS_METRIC => counts.requests += count.value(),
582                BYTES_READ_METRIC => counts.bytes_read += count.value(),
583                INDICES_LOADED_METRIC => counts.indices_loaded += count.value(),
584                PARTS_LOADED_METRIC => counts.parts_loaded += count.value(),
585                INDEX_COMPARISONS_METRIC => counts.index_comparisons += count.value(),
586                _ => {
587                    let existing = counts
588                        .all_counts
589                        .entry(metric_name.as_ref().to_string())
590                        .or_insert(0);
591                    *existing += count.value();
592                }
593            }
594        }
595        for (metric_name, time) in metrics.iter_times() {
596            let existing = counts
597                .all_times
598                .entry(metric_name.as_ref().to_string())
599                .or_insert(0);
600            *existing += time.value();
601        }
602        // Include gauge-based I/O metrics (some nodes record I/O as gauges)
603        for (metric_name, gauge) in metrics.iter_gauges() {
604            match metric_name.as_ref() {
605                IOPS_METRIC => counts.iops += gauge.value(),
606                REQUESTS_METRIC => counts.requests += gauge.value(),
607                BYTES_READ_METRIC => counts.bytes_read += gauge.value(),
608                _ => {}
609            }
610        }
611    }
612    for child in node.children() {
613        collect_execution_metrics(child.as_ref(), counts);
614    }
615}
616
617fn report_plan_summary_metrics(plan: &dyn ExecutionPlan, options: &LanceExecutionOptions) {
618    let output_rows = plan
619        .metrics()
620        .map(|m| m.output_rows().unwrap_or(0))
621        .unwrap_or(0);
622    let mut counts = ExecutionSummaryCounts::default();
623    collect_execution_metrics(plan, &mut counts);
624    if !options.skip_logging {
625        tracing::info!(
626            target: TRACE_EXECUTION,
627            r#type = EXECUTION_PLAN_RUN,
628            plan_summary = display_plan_one_liner(plan),
629            output_rows,
630            iops = counts.iops,
631            requests = counts.requests,
632            bytes_read = counts.bytes_read,
633            indices_loaded = counts.indices_loaded,
634            parts_loaded = counts.parts_loaded,
635            index_comparisons = counts.index_comparisons,
636            index_cache_hits = counts.index_cache_hits(),
637            index_cache_misses = counts.index_cache_misses(),
638        );
639    }
640    if let Some(callback) = options.execution_stats_callback.as_ref() {
641        callback(&counts);
642    }
643}
644
645/// Create a one-line rough summary of the given execution plan.
646///
647/// The summary just shows the name of the operators in the plan. It omits any
648/// details such as parameters or schema information.
649///
650/// Example: `Projection(Take(CoalesceBatches(Filter(LanceScan))))`
651fn display_plan_one_liner(plan: &dyn ExecutionPlan) -> String {
652    let mut output = String::new();
653
654    display_plan_one_liner_impl(plan, &mut output);
655
656    output
657}
658
659fn display_plan_one_liner_impl(plan: &dyn ExecutionPlan, output: &mut String) {
660    // Remove the "Exec" suffix from the plan name if present for brevity
661    let name = plan.name().trim_end_matches("Exec");
662    output.push_str(name);
663
664    let children = plan.children();
665    if !children.is_empty() {
666        output.push('(');
667        for (i, child) in children.iter().enumerate() {
668            if i > 0 {
669                output.push(',');
670            }
671            display_plan_one_liner_impl(child.as_ref(), output);
672        }
673        output.push(')');
674    }
675}
676
677/// Executes a plan using default session & runtime configuration
678///
679/// Only executes a single partition.  Panics if the plan has more than one partition.
680pub fn execute_plan(
681    plan: Arc<dyn ExecutionPlan>,
682    options: LanceExecutionOptions,
683) -> Result<SendableRecordBatchStream> {
684    if !options.skip_logging {
685        debug!(
686            "Executing plan:\n{}",
687            DisplayableExecutionPlan::new(plan.as_ref()).indent(true)
688        );
689    }
690
691    let session_ctx = get_session_context(&options);
692
693    // Coalesce to a single partition if the optimizer left more than one.
694    // EnforceDistribution may remove RepartitionExec(1) nodes when the parent
695    // declares UnspecifiedDistribution, leaving multi-partition plans here.
696    //
697    // If the plan carries an output ordering (e.g. a top-k `SortExec` whose
698    // result was later repartitioned to parallelize downstream operators),
699    // a plain `CoalescePartitionsExec` would scramble that order because it
700    // merges partitions in scheduling-dependent order. Use an order-preserving
701    // merge in that case instead, mirroring what `EnforceDistribution` itself
702    // does when it needs to merge an ordered, multi-partition plan.
703    let plan: Arc<dyn ExecutionPlan> = if plan.properties().partitioning.partition_count() == 1 {
704        plan
705    } else if let Some(ordering) = plan.output_ordering() {
706        Arc::new(SortPreservingMergeExec::new(ordering.clone(), plan))
707    } else {
708        Arc::new(CoalescePartitionsExec::new(plan))
709    };
710
711    let stream = plan.execute(0, get_task_context(&session_ctx, &options))?;
712
713    let schema = stream.schema();
714    let stream = stream.finally(move || {
715        if !options.skip_logging || options.execution_stats_callback.is_some() {
716            report_plan_summary_metrics(plan.as_ref(), &options);
717        }
718    });
719    Ok(Box::pin(RecordBatchStreamAdapter::new(schema, stream)))
720}
721
722pub async fn analyze_plan(
723    plan: Arc<dyn ExecutionPlan>,
724    options: LanceExecutionOptions,
725) -> Result<String> {
726    analyze_plan_with_context(plan, options, None).await
727}
728
729/// Analyze a plan, optionally under a caller-provided [`TaskContext`].
730///
731/// When `task_context` is `Some`, the plan executes under it instead of the
732/// context derived from `options`. Callers whose nodes read session-config
733/// extensions at execution time (e.g. distributed routing identity) must pass
734/// the context carrying those extensions; otherwise the nodes error during
735/// `execute` and `AnalyzeExec` reports an empty, unexecuted plan tree instead
736/// of surfacing the error.
737pub async fn analyze_plan_with_context(
738    plan: Arc<dyn ExecutionPlan>,
739    options: LanceExecutionOptions,
740    task_context: Option<Arc<TaskContext>>,
741) -> Result<String> {
742    // This is needed as AnalyzeExec launches a thread task per
743    // partition, and we want these to be connected to the parent span
744    let plan = Arc::new(TracedExec::new(plan, Span::current()));
745
746    let schema = plan.schema();
747    // TODO(tsaucer) I chose SUMMARY here but do we also want DEV?
748    let analyze = Arc::new(AnalyzeExec::new(
749        true,
750        true,
751        vec![MetricType::Summary],
752        None,
753        plan,
754        schema,
755    ));
756
757    let session_ctx = get_session_context(&options);
758    let task_context = task_context.unwrap_or_else(|| get_task_context(&session_ctx, &options));
759    assert_eq!(analyze.properties().partitioning.partition_count(), 1);
760    let mut stream = analyze
761        .execute(0, task_context)
762        .map_err(|err| Error::io(format!("Failed to execute analyze plan: {}", err)))?;
763
764    // fully execute the plan
765    while (stream.next().await).is_some() {}
766
767    let result = format_plan(analyze);
768    Ok(result)
769}
770
771pub fn format_plan(plan: Arc<dyn ExecutionPlan>) -> String {
772    /// A visitor which calculates additional metrics for all the plans.
773    struct CalculateVisitor {
774        highest_index: usize,
775        index_to_elapsed: HashMap<usize, Duration>,
776    }
777
778    /// Result of calculating metrics for a subtree
779    struct SubtreeMetrics {
780        min_start: Option<DateTime<Utc>>,
781        max_end: Option<DateTime<Utc>>,
782    }
783
784    impl CalculateVisitor {
785        fn calculate_metrics(&mut self, plan: &Arc<dyn ExecutionPlan>) -> SubtreeMetrics {
786            self.highest_index += 1;
787            let plan_index = self.highest_index;
788
789            // Get timestamps for this node
790            let (mut min_start, mut max_end) = Self::node_timerange(plan);
791
792            // Accumulate from children
793            for child in plan.children() {
794                let child_metrics = self.calculate_metrics(child);
795                min_start = Self::min_option(min_start, child_metrics.min_start);
796                max_end = Self::max_option(max_end, child_metrics.max_end);
797            }
798
799            // Calculate wall clock duration for this subtree (only if we have timestamps)
800            let elapsed = match (min_start, max_end) {
801                (Some(start), Some(end)) => Some((end - start).to_std().unwrap_or_default()),
802                _ => None,
803            };
804
805            if let Some(e) = elapsed {
806                self.index_to_elapsed.insert(plan_index, e);
807            }
808
809            SubtreeMetrics { min_start, max_end }
810        }
811
812        fn node_timerange(
813            plan: &Arc<dyn ExecutionPlan>,
814        ) -> (Option<DateTime<Utc>>, Option<DateTime<Utc>>) {
815            let Some(metrics) = plan.metrics() else {
816                return (None, None);
817            };
818            let min_start = metrics
819                .iter()
820                .filter_map(|m| match m.value() {
821                    MetricValue::StartTimestamp(ts) => ts.value(),
822                    _ => None,
823                })
824                .min();
825            let max_end = metrics
826                .iter()
827                .filter_map(|m| match m.value() {
828                    MetricValue::EndTimestamp(ts) => ts.value(),
829                    _ => None,
830                })
831                .max();
832            (min_start, max_end)
833        }
834
835        fn min_option(a: Option<DateTime<Utc>>, b: Option<DateTime<Utc>>) -> Option<DateTime<Utc>> {
836            [a, b].into_iter().flatten().min()
837        }
838
839        fn max_option(a: Option<DateTime<Utc>>, b: Option<DateTime<Utc>>) -> Option<DateTime<Utc>> {
840            [a, b].into_iter().flatten().max()
841        }
842    }
843
844    /// A visitor which prints out all the plans.
845    struct PrintVisitor {
846        highest_index: usize,
847        indent: usize,
848    }
849    impl PrintVisitor {
850        fn write_output(
851            &mut self,
852            plan: &Arc<dyn ExecutionPlan>,
853            f: &mut Formatter,
854            calcs: &CalculateVisitor,
855        ) -> std::fmt::Result {
856            self.highest_index += 1;
857            write!(f, "{:indent$}", "", indent = self.indent * 2)?;
858
859            // Format the plan description
860            let displayable =
861                datafusion::physical_plan::display::DisplayableExecutionPlan::new(plan.as_ref());
862            let plan_str = displayable.one_line().to_string();
863            let plan_str = plan_str.trim();
864
865            // Write operator with elapsed time inserted after the name
866            match calcs.index_to_elapsed.get(&self.highest_index) {
867                Some(elapsed) => match plan_str.find(": ") {
868                    Some(i) => write!(
869                        f,
870                        "{}: elapsed={elapsed:?}, {}",
871                        &plan_str[..i],
872                        &plan_str[i + 2..]
873                    )?,
874                    None => write!(f, "{plan_str}, elapsed={elapsed:?}")?,
875                },
876                None => write!(f, "{plan_str}")?,
877            }
878
879            if let Some(metrics) = plan.metrics() {
880                let metrics = metrics
881                    .aggregate_by_name()
882                    .sorted_for_display()
883                    .timestamps_removed();
884
885                write!(f, ", metrics=[{metrics}]")?;
886            } else {
887                write!(f, ", metrics=[]")?;
888            }
889            writeln!(f)?;
890            self.indent += 1;
891            for child in plan.children() {
892                self.write_output(child, f, calcs)?;
893            }
894            self.indent -= 1;
895            std::fmt::Result::Ok(())
896        }
897    }
898    // A wrapper which prints out a plan.
899    struct PrintWrapper {
900        plan: Arc<dyn ExecutionPlan>,
901    }
902    impl fmt::Display for PrintWrapper {
903        fn fmt(&self, f: &mut Formatter) -> std::fmt::Result {
904            let mut calcs = CalculateVisitor {
905                highest_index: 0,
906                index_to_elapsed: HashMap::new(),
907            };
908            calcs.calculate_metrics(&self.plan);
909            let mut prints = PrintVisitor {
910                highest_index: 0,
911                indent: 0,
912            };
913            prints.write_output(&self.plan, f, &calcs)
914        }
915    }
916    let wrapper = PrintWrapper { plan };
917    format!("{}", wrapper)
918}
919
920pub trait SessionContextExt {
921    /// Creates a DataFrame for reading a stream of data
922    ///
923    /// This dataframe may only be queried once, future queries will fail
924    fn read_one_shot(
925        &self,
926        data: SendableRecordBatchStream,
927    ) -> datafusion::common::Result<DataFrame>;
928}
929
930pub struct OneShotPartitionStream {
931    data: Arc<Mutex<Option<SendableRecordBatchStream>>>,
932    schema: Arc<ArrowSchema>,
933}
934
935impl std::fmt::Debug for OneShotPartitionStream {
936    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
937        let data = self.data.lock().unwrap();
938        f.debug_struct("OneShotPartitionStream")
939            .field("exhausted", &data.is_none())
940            .field("schema", self.schema.as_ref())
941            .finish()
942    }
943}
944
945impl OneShotPartitionStream {
946    pub fn new(data: SendableRecordBatchStream) -> Self {
947        let schema = data.schema();
948        Self {
949            data: Arc::new(Mutex::new(Some(data))),
950            schema,
951        }
952    }
953}
954
955impl PartitionStream for OneShotPartitionStream {
956    fn schema(&self) -> &arrow_schema::SchemaRef {
957        &self.schema
958    }
959
960    fn execute(&self, _ctx: Arc<TaskContext>) -> SendableRecordBatchStream {
961        let mut stream = self.data.lock().unwrap();
962        stream
963            .take()
964            .expect("Attempt to consume a one shot dataframe multiple times")
965    }
966}
967
968impl SessionContextExt for SessionContext {
969    fn read_one_shot(
970        &self,
971        data: SendableRecordBatchStream,
972    ) -> datafusion::common::Result<DataFrame> {
973        let schema = data.schema();
974        let part_stream = Arc::new(OneShotPartitionStream::new(data));
975        let provider = StreamingTable::try_new(schema, vec![part_stream])?;
976        self.read_table(Arc::new(provider))
977    }
978}
979
980/// Scan a [`TableProvider`] into a single-partition [`SendableRecordBatchStream`].
981///
982/// Multi-partition providers are coalesced into a single partition. This adapts a
983/// re-scannable provider back into the one stream the writer pipeline consumes;
984/// re-scanning the same provider (e.g. on a write retry) yields a fresh stream.
985///
986/// # Examples
987///
988/// ```
989/// # use std::sync::Arc;
990/// # use arrow_array::{Int32Array, RecordBatch};
991/// # use arrow_schema::{DataType, Field, Schema};
992/// # use datafusion::catalog::TableProvider;
993/// # use datafusion::datasource::MemTable;
994/// # use futures::TryStreamExt;
995/// # use lance_datafusion::exec::provider_to_stream;
996/// # #[tokio::main]
997/// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
998/// let schema = Arc::new(Schema::new(vec![Field::new("a", DataType::Int32, false)]));
999/// let batch =
1000///     RecordBatch::try_new(schema.clone(), vec![Arc::new(Int32Array::from(vec![1, 2, 3]))])?;
1001/// let provider: Arc<dyn TableProvider> = Arc::new(MemTable::try_new(schema, vec![vec![batch]])?);
1002///
1003/// // A re-scannable provider yields a fresh stream on each call.
1004/// let batches: Vec<RecordBatch> = provider_to_stream(provider).await?.try_collect().await?;
1005/// assert_eq!(batches.iter().map(|b| b.num_rows()).sum::<usize>(), 3);
1006/// # Ok(())
1007/// # }
1008/// ```
1009pub async fn provider_to_stream(
1010    provider: Arc<dyn TableProvider>,
1011) -> Result<SendableRecordBatchStream> {
1012    let ctx = SessionContext::new();
1013    let plan = provider.scan(&ctx.state(), None, &[], None).await?;
1014    let plan: Arc<dyn ExecutionPlan> =
1015        if plan.properties().output_partitioning().partition_count() > 1 {
1016            Arc::new(CoalescePartitionsExec::new(plan))
1017        } else {
1018            plan
1019        };
1020    Ok(plan.execute(0, ctx.task_ctx())?)
1021}
1022
1023#[derive(Clone, Debug)]
1024pub struct StrictBatchSizeExec {
1025    input: Arc<dyn ExecutionPlan>,
1026    batch_size: usize,
1027}
1028
1029impl StrictBatchSizeExec {
1030    pub fn new(input: Arc<dyn ExecutionPlan>, batch_size: usize) -> Self {
1031        Self { input, batch_size }
1032    }
1033}
1034
1035impl DisplayAs for StrictBatchSizeExec {
1036    fn fmt_as(
1037        &self,
1038        _t: datafusion::physical_plan::DisplayFormatType,
1039        f: &mut std::fmt::Formatter,
1040    ) -> std::fmt::Result {
1041        write!(f, "StrictBatchSizeExec")
1042    }
1043}
1044
1045impl ExecutionPlan for StrictBatchSizeExec {
1046    fn name(&self) -> &str {
1047        "StrictBatchSizeExec"
1048    }
1049
1050    fn properties(&self) -> &Arc<PlanProperties> {
1051        self.input.properties()
1052    }
1053
1054    fn children(&self) -> Vec<&Arc<dyn ExecutionPlan>> {
1055        vec![&self.input]
1056    }
1057
1058    fn with_new_children(
1059        self: Arc<Self>,
1060        children: Vec<Arc<dyn ExecutionPlan>>,
1061    ) -> datafusion_common::Result<Arc<dyn ExecutionPlan>> {
1062        Ok(Arc::new(Self {
1063            input: children[0].clone(),
1064            batch_size: self.batch_size,
1065        }))
1066    }
1067
1068    fn execute(
1069        &self,
1070        partition: usize,
1071        context: Arc<TaskContext>,
1072    ) -> datafusion_common::Result<SendableRecordBatchStream> {
1073        let stream = self.input.execute(partition, context)?;
1074        let schema = stream.schema();
1075        let stream = StrictBatchSizeStream::new(stream, self.batch_size);
1076        Ok(Box::pin(RecordBatchStreamAdapter::new(schema, stream)))
1077    }
1078
1079    fn maintains_input_order(&self) -> Vec<bool> {
1080        vec![true]
1081    }
1082
1083    fn benefits_from_input_partitioning(&self) -> Vec<bool> {
1084        vec![false]
1085    }
1086
1087    fn partition_statistics(
1088        &self,
1089        partition: Option<usize>,
1090    ) -> datafusion_common::Result<std::sync::Arc<Statistics>> {
1091        self.input.partition_statistics(partition)
1092    }
1093
1094    fn cardinality_effect(&self) -> CardinalityEffect {
1095        CardinalityEffect::Equal
1096    }
1097
1098    fn supports_limit_pushdown(&self) -> bool {
1099        true
1100    }
1101}
1102
1103/// Exec node that rechunks batches so no output batch exceeds `max_bytes`.
1104///
1105/// # Why this exists
1106///
1107/// DataFusion's sort operator cannot handle batches larger than the memory
1108/// pool size.  When upstream operators produce very large batches this can
1109/// cause the sort to fail.  This node caps batch sizes
1110/// *before* the sort so the operation succeeds.  The trade-off is a
1111/// potentially expensive deep copy of the batch data — see below — but that
1112/// is preferable to failing the operation entirely.  This workaround may
1113/// become unnecessary if a fix is upstreamed to DataFusion.
1114///
1115/// # Deep copy
1116///
1117/// After slicing a RecordBatch, `get_array_memory_size` still reports the
1118/// size of the *original* backing buffers, not the slice.  To get accurate
1119/// sizes the slices must be deep-copied.  This is a last resort and can be
1120/// expensive for large batches, but the deep copy is only performed when a
1121/// batch actually needs to be sliced — batches that are already within the
1122/// target range pass through at zero cost.
1123///
1124/// If a single row exceeds `max_bytes`, execution fails with an error.
1125#[derive(Clone, Debug)]
1126pub struct HardCapBatchSizeExec {
1127    input: Arc<dyn ExecutionPlan>,
1128    max_bytes: usize,
1129}
1130
1131impl HardCapBatchSizeExec {
1132    pub fn new(input: Arc<dyn ExecutionPlan>, max_bytes: usize) -> Self {
1133        Self { input, max_bytes }
1134    }
1135}
1136
1137impl DisplayAs for HardCapBatchSizeExec {
1138    fn fmt_as(
1139        &self,
1140        _t: datafusion::physical_plan::DisplayFormatType,
1141        f: &mut std::fmt::Formatter,
1142    ) -> std::fmt::Result {
1143        write!(f, "HardCapBatchSizeExec(max_bytes={})", self.max_bytes)
1144    }
1145}
1146
1147impl ExecutionPlan for HardCapBatchSizeExec {
1148    fn name(&self) -> &str {
1149        "HardCapBatchSizeExec"
1150    }
1151
1152    fn properties(&self) -> &Arc<PlanProperties> {
1153        self.input.properties()
1154    }
1155
1156    fn children(&self) -> Vec<&Arc<dyn ExecutionPlan>> {
1157        vec![&self.input]
1158    }
1159
1160    fn with_new_children(
1161        self: Arc<Self>,
1162        children: Vec<Arc<dyn ExecutionPlan>>,
1163    ) -> datafusion_common::Result<Arc<dyn ExecutionPlan>> {
1164        Ok(Arc::new(Self {
1165            input: children[0].clone(),
1166            max_bytes: self.max_bytes,
1167        }))
1168    }
1169
1170    fn execute(
1171        &self,
1172        partition: usize,
1173        context: Arc<TaskContext>,
1174    ) -> datafusion_common::Result<SendableRecordBatchStream> {
1175        let stream = self.input.execute(partition, context)?;
1176        let schema = stream.schema();
1177        let max_bytes = self.max_bytes;
1178        let rechunked = lance_arrow::stream::rechunk_stream_by_size_deep_copy(
1179            stream,
1180            schema.clone(),
1181            0,
1182            max_bytes,
1183        );
1184        // Check that no single-row batch exceeds the limit.
1185        let validated = rechunked.map(move |result| {
1186            let batch = result?;
1187            if batch.num_rows() == 1 && batch.get_array_memory_size() > max_bytes {
1188                return Err(DataFusionError::External(Box::new(Error::invalid_input(
1189                    format!(
1190                        "a single row is {} bytes which exceeds the maximum allowed batch \
1191                         size of {} bytes",
1192                        batch.get_array_memory_size(),
1193                        max_bytes,
1194                    ),
1195                ))));
1196            }
1197            Ok(batch)
1198        });
1199        Ok(Box::pin(RecordBatchStreamAdapter::new(schema, validated)))
1200    }
1201
1202    fn maintains_input_order(&self) -> Vec<bool> {
1203        vec![true]
1204    }
1205
1206    fn benefits_from_input_partitioning(&self) -> Vec<bool> {
1207        vec![false]
1208    }
1209
1210    fn partition_statistics(
1211        &self,
1212        partition: Option<usize>,
1213    ) -> datafusion_common::Result<std::sync::Arc<Statistics>> {
1214        self.input.partition_statistics(partition)
1215    }
1216
1217    fn cardinality_effect(&self) -> CardinalityEffect {
1218        CardinalityEffect::Equal
1219    }
1220
1221    fn supports_limit_pushdown(&self) -> bool {
1222        true
1223    }
1224}
1225
1226#[cfg(test)]
1227mod tests {
1228    use super::*;
1229
1230    // Serialize cache tests since they share global state
1231    static CACHE_TEST_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
1232
1233    #[test]
1234    fn test_session_context_cache() {
1235        let _lock = CACHE_TEST_LOCK.lock().unwrap();
1236        let cache = get_session_cache();
1237
1238        // Clear any existing entries from other tests
1239        cache.lock().unwrap().clear();
1240
1241        // Create first session with default options
1242        let opts1 = LanceExecutionOptions::default();
1243        let _ctx1 = get_session_context(&opts1);
1244
1245        {
1246            let cache_guard = cache.lock().unwrap();
1247            assert_eq!(cache_guard.len(), 1);
1248        }
1249
1250        // Same options should reuse cached session (no new entry)
1251        let _ctx1_again = get_session_context(&opts1);
1252        {
1253            let cache_guard = cache.lock().unwrap();
1254            assert_eq!(cache_guard.len(), 1);
1255        }
1256
1257        // Different options should create new entry
1258        let opts2 = LanceExecutionOptions {
1259            use_spilling: true,
1260            ..Default::default()
1261        };
1262        let _ctx2 = get_session_context(&opts2);
1263        {
1264            let cache_guard = cache.lock().unwrap();
1265            assert_eq!(cache_guard.len(), 2);
1266        }
1267    }
1268
1269    #[test]
1270    fn test_session_context_cache_lru_eviction() {
1271        let _lock = CACHE_TEST_LOCK.lock().unwrap();
1272        let cache = get_session_cache();
1273
1274        // Clear any existing entries from other tests
1275        cache.lock().unwrap().clear();
1276
1277        // Create 4 different configurations to fill the cache
1278        let configs: Vec<LanceExecutionOptions> = (0..4)
1279            .map(|i| LanceExecutionOptions {
1280                mem_pool_size: Some((i + 1) as u64 * 1024 * 1024),
1281                ..Default::default()
1282            })
1283            .collect();
1284
1285        for config in &configs {
1286            let _ctx = get_session_context(config);
1287        }
1288
1289        {
1290            let cache_guard = cache.lock().unwrap();
1291            assert_eq!(cache_guard.len(), 4);
1292        }
1293
1294        // Access config[0] to make it more recently used than config[1]
1295        // (config[0] was inserted first, so without this access it would be evicted)
1296        std::thread::sleep(std::time::Duration::from_millis(1));
1297        let _ctx = get_session_context(&configs[0]);
1298
1299        // Add a 5th configuration - should evict config[1] (now least recently used)
1300        let opts5 = LanceExecutionOptions {
1301            mem_pool_size: Some(5 * 1024 * 1024),
1302            ..Default::default()
1303        };
1304        let _ctx5 = get_session_context(&opts5);
1305
1306        {
1307            let cache_guard = cache.lock().unwrap();
1308            assert_eq!(cache_guard.len(), 4);
1309
1310            // config[0] should still be present (was accessed recently)
1311            let key0 = SessionContextCacheKey::from_options(&configs[0]);
1312            assert!(
1313                cache_guard.contains_key(&key0),
1314                "config[0] should still be cached after recent access"
1315            );
1316
1317            // config[1] should be evicted (was least recently used)
1318            let key1 = SessionContextCacheKey::from_options(&configs[1]);
1319            assert!(
1320                !cache_guard.contains_key(&key1),
1321                "config[1] should have been evicted"
1322            );
1323
1324            // New config should be present
1325            let key5 = SessionContextCacheKey::from_options(&opts5);
1326            assert!(
1327                cache_guard.contains_key(&key5),
1328                "new config should be cached"
1329            );
1330        }
1331    }
1332
1333    #[test]
1334    fn test_mem_pool_size_scales_with_partitions() {
1335        let default_per_partition = DEFAULT_LANCE_MEM_POOL_SIZE_PER_PARTITION;
1336
1337        // No partitions specified → defaults to 1 partition
1338        let opts = LanceExecutionOptions::default();
1339        assert_eq!(opts.mem_pool_size(), default_per_partition);
1340
1341        // 4 partitions → 4x the per-partition size
1342        let opts = LanceExecutionOptions {
1343            target_partition: Some(4),
1344            ..Default::default()
1345        };
1346        assert_eq!(opts.mem_pool_size(), default_per_partition * 4);
1347
1348        // 8 partitions → 8x the per-partition size
1349        let opts = LanceExecutionOptions {
1350            target_partition: Some(8),
1351            ..Default::default()
1352        };
1353        assert_eq!(opts.mem_pool_size(), default_per_partition * 8);
1354
1355        // Explicit mem_pool_size is not scaled
1356        let opts = LanceExecutionOptions {
1357            mem_pool_size: Some(50 * 1024 * 1024),
1358            target_partition: Some(8),
1359            ..Default::default()
1360        };
1361        assert_eq!(opts.mem_pool_size(), 50 * 1024 * 1024);
1362    }
1363
1364    /// A marker a node reads from the session-config extensions at execute time.
1365    #[derive(Debug)]
1366    struct RequiredExtension;
1367
1368    /// Execution node that only succeeds when [`RequiredExtension`] is present
1369    /// on the task context's session config. This mirrors distributed routing
1370    /// nodes that read a session-config identity extension during `execute`.
1371    #[derive(Debug)]
1372    struct NeedsExtensionExec {
1373        properties: Arc<PlanProperties>,
1374        /// Set once the node reaches execution with the extension present.
1375        /// Observed by the test so that dropping context forwarding (which
1376        /// makes `execute` error before this point) is detectable.
1377        executed: Arc<std::sync::atomic::AtomicBool>,
1378    }
1379
1380    impl NeedsExtensionExec {
1381        fn new(executed: Arc<std::sync::atomic::AtomicBool>) -> Self {
1382            let schema = Arc::new(ArrowSchema::empty());
1383            Self {
1384                properties: Arc::new(PlanProperties::new(
1385                    EquivalenceProperties::new(schema),
1386                    Partitioning::UnknownPartitioning(1),
1387                    EmissionType::Incremental,
1388                    Boundedness::Bounded,
1389                )),
1390                executed,
1391            }
1392        }
1393    }
1394
1395    impl DisplayAs for NeedsExtensionExec {
1396        fn fmt_as(&self, _t: DisplayFormatType, f: &mut Formatter) -> fmt::Result {
1397            write!(f, "NeedsExtensionExec")
1398        }
1399    }
1400
1401    impl ExecutionPlan for NeedsExtensionExec {
1402        fn name(&self) -> &str {
1403            "NeedsExtensionExec"
1404        }
1405        fn properties(&self) -> &Arc<PlanProperties> {
1406            &self.properties
1407        }
1408        fn children(&self) -> Vec<&Arc<dyn ExecutionPlan>> {
1409            vec![]
1410        }
1411        fn with_new_children(
1412            self: Arc<Self>,
1413            _children: Vec<Arc<dyn ExecutionPlan>>,
1414        ) -> datafusion_common::Result<Arc<dyn ExecutionPlan>> {
1415            Ok(self)
1416        }
1417        fn execute(
1418            &self,
1419            _partition: usize,
1420            context: Arc<TaskContext>,
1421        ) -> datafusion_common::Result<SendableRecordBatchStream> {
1422            if context
1423                .session_config()
1424                .get_extension::<RequiredExtension>()
1425                .is_none()
1426            {
1427                return Err(DataFusionError::Execution(
1428                    "missing required session-config extension".to_string(),
1429                ));
1430            }
1431            self.executed
1432                .store(true, std::sync::atomic::Ordering::SeqCst);
1433            let schema = self.schema();
1434            Ok(Box::pin(RecordBatchStreamAdapter::new(
1435                schema,
1436                stream::empty(),
1437            )))
1438        }
1439    }
1440
1441    // Regression: analyze must run under a caller-provided TaskContext so nodes
1442    // that read a session-config extension at execute time see it. Without the
1443    // context the node errors and AnalyzeExec would otherwise report an empty,
1444    // unexecuted plan tree.
1445    #[tokio::test]
1446    async fn test_analyze_plan_uses_provided_task_context() {
1447        use std::sync::atomic::{AtomicBool, Ordering};
1448
1449        let executed = Arc::new(AtomicBool::new(false));
1450        let plan: Arc<dyn ExecutionPlan> = Arc::new(NeedsExtensionExec::new(executed.clone()));
1451
1452        // Default context lacks the extension: the node errors during execute
1453        // (never reaching the `executed` flag), but AnalyzeExec absorbs that
1454        // per-partition failure and reports an empty, unexecuted plan tree
1455        // rather than propagating the error. This is the regression symptom.
1456        let report = analyze_plan(plan.clone(), LanceExecutionOptions::default())
1457            .await
1458            .expect("AnalyzeExec swallows the node's execute error into an Ok report");
1459        assert!(
1460            report.contains("NeedsExtensionExec, metrics=[]"),
1461            "expected an empty, unexecuted NeedsExtensionExec node, got: {report}"
1462        );
1463        assert!(
1464            !executed.load(Ordering::SeqCst),
1465            "node must not execute successfully without the extension"
1466        );
1467
1468        // A context carrying the extension executes the node successfully.
1469        let options = LanceExecutionOptions::default();
1470        let session_ctx = get_session_context(&options);
1471        let config = session_ctx
1472            .task_ctx()
1473            .session_config()
1474            .clone()
1475            .with_extension(Arc::new(RequiredExtension));
1476        let task_ctx = session_ctx.task_ctx();
1477        let task_ctx = Arc::new(TaskContext::new(
1478            task_ctx.task_id(),
1479            task_ctx.session_id(),
1480            config,
1481            task_ctx.scalar_functions().clone(),
1482            task_ctx.higher_order_functions().clone(),
1483            task_ctx.aggregate_functions().clone(),
1484            task_ctx.window_functions().clone(),
1485            task_ctx.runtime_env(),
1486        ));
1487        let report = analyze_plan_with_context(plan, options, Some(task_ctx))
1488            .await
1489            .expect("analyze should succeed when the extension is present");
1490        assert!(report.contains("NeedsExtensionExec"));
1491        // The node only reaches this flag when the supplied context is actually
1492        // forwarded to `execute`; dropping the forwarding fails this assertion.
1493        assert!(
1494            executed.load(Ordering::SeqCst),
1495            "supplied context must be forwarded so the node executes"
1496        );
1497    }
1498}