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, utils::get_available_parallelism};
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;
310/// Maximum headroom reserved for one external sort's non-spillable merge phase.
311const MAX_SORT_SPILL_RESERVATION_BYTES: u64 = 40 * 1024 * 1024;
312const DEFAULT_LANCE_MAX_TEMP_DIRECTORY_SIZE: u64 = 100 * 1024 * 1024 * 1024; // 100GB
313
314impl LanceExecutionOptions {
315    /// The number of partitions the DataFusion session will actually run with.
316    ///
317    /// When `target_partition` is not set we do not override the session config,
318    /// so the session falls back to DataFusion's default `target_partitions`,
319    /// which is the available parallelism (number of CPU cores). The memory pool
320    /// must be sized for that effective partition count, not for a single
321    /// partition, or sort-heavy plans exhaust the pool.
322    fn effective_target_partition(&self) -> u64 {
323        self.target_partition
324            .unwrap_or_else(get_available_parallelism) as u64
325    }
326
327    pub fn mem_pool_size(&self) -> u64 {
328        let num_partitions = self.effective_target_partition();
329        self.mem_pool_size.unwrap_or_else(|| {
330            std::env::var("LANCE_MEM_POOL_SIZE")
331                .map(|s| match s.parse::<u64>() {
332                    Ok(v) => v,
333                    Err(e) => {
334                        warn!("Failed to parse LANCE_MEM_POOL_SIZE: {}, using default", e);
335                        DEFAULT_LANCE_MEM_POOL_SIZE_PER_PARTITION * num_partitions
336                    }
337                })
338                .unwrap_or(DEFAULT_LANCE_MEM_POOL_SIZE_PER_PARTITION * num_partitions)
339        })
340    }
341
342    pub fn max_temp_directory_size(&self) -> u64 {
343        self.max_temp_directory_size.unwrap_or_else(|| {
344            std::env::var("LANCE_MAX_TEMP_DIRECTORY_SIZE")
345                .map(|s| match s.parse::<u64>() {
346                    Ok(v) => v,
347                    Err(e) => {
348                        warn!(
349                            "Failed to parse LANCE_MAX_TEMP_DIRECTORY_SIZE: {}, using default",
350                            e
351                        );
352                        DEFAULT_LANCE_MAX_TEMP_DIRECTORY_SIZE
353                    }
354                })
355                .unwrap_or(DEFAULT_LANCE_MAX_TEMP_DIRECTORY_SIZE)
356        })
357    }
358
359    pub fn use_spilling(&self) -> bool {
360        if !self.use_spilling {
361            return false;
362        }
363        // Presence enables the bypass; the value is not parsed as a boolean.
364        std::env::var("LANCE_BYPASS_SPILLING")
365            .map(|_| {
366                info!("Bypassing spilling because LANCE_BYPASS_SPILLING is set");
367                false
368            })
369            .unwrap_or(true)
370    }
371}
372
373pub fn new_session_context(options: &LanceExecutionOptions) -> SessionContext {
374    let mut session_config = SessionConfig::new();
375    let mut runtime_env_builder = RuntimeEnvBuilder::new();
376    if let Some(target_partition) = options.target_partition {
377        session_config = session_config.with_target_partitions(target_partition);
378    }
379    if options.use_spilling() {
380        // Reserve sort/merge headroom for each spillable sort, using up to 40 MiB
381        // instead of DataFusion's 10 MiB default. Limit it to one third of the pool
382        // to leave room for input batches in small pools. This reservation comes
383        // out of the same pool; it does not guarantee that every batch will fit.
384        let sort_spill_reservation_bytes =
385            (options.mem_pool_size() / 3).min(MAX_SORT_SPILL_RESERVATION_BYTES) as usize;
386        session_config =
387            session_config.with_sort_spill_reservation_bytes(sort_spill_reservation_bytes);
388        let disk_manager_builder = DiskManagerBuilder::default()
389            .with_max_temp_directory_size(options.max_temp_directory_size());
390        runtime_env_builder = runtime_env_builder
391            .with_disk_manager_builder(disk_manager_builder)
392            .with_memory_pool(Arc::new(TrackConsumersPool::new(
393                FairSpillPool::new(options.mem_pool_size() as usize),
394                NonZero::try_from(16).unwrap(),
395            )));
396    }
397    // Without spilling, DataFusion's default UnboundedMemoryPool accepts all
398    // reservations. This bypasses the configured pool limit, not actual RAM limits.
399    let runtime_env = runtime_env_builder.build_arc().unwrap();
400
401    let ctx = SessionContext::new_with_config_rt(session_config, runtime_env);
402    register_functions(&ctx);
403
404    ctx
405}
406
407/// Cache key for session contexts based on resolved configuration values.
408#[derive(Clone, Debug, PartialEq, Eq, Hash)]
409struct SessionContextCacheKey {
410    mem_pool_size: u64,
411    max_temp_directory_size: u64,
412    target_partition: Option<usize>,
413}
414
415impl SessionContextCacheKey {
416    fn from_options(options: &LanceExecutionOptions) -> Self {
417        Self {
418            mem_pool_size: options.mem_pool_size(),
419            max_temp_directory_size: options.max_temp_directory_size(),
420            target_partition: options.target_partition,
421        }
422    }
423}
424
425struct CachedSessionContext {
426    context: SessionContext,
427    last_access: std::time::Instant,
428}
429
430fn get_session_cache() -> &'static Mutex<HashMap<SessionContextCacheKey, CachedSessionContext>> {
431    static SESSION_CACHE: OnceLock<Mutex<HashMap<SessionContextCacheKey, CachedSessionContext>>> =
432        OnceLock::new();
433    SESSION_CACHE.get_or_init(|| Mutex::new(HashMap::new()))
434}
435
436fn get_max_cache_size() -> usize {
437    const DEFAULT_CACHE_SIZE: usize = 4;
438    static MAX_CACHE_SIZE: OnceLock<usize> = OnceLock::new();
439    *MAX_CACHE_SIZE.get_or_init(|| {
440        std::env::var("LANCE_SESSION_CACHE_SIZE")
441            .ok()
442            .and_then(|v| v.parse().ok())
443            .unwrap_or(DEFAULT_CACHE_SIZE)
444    })
445}
446
447/// Reuses unbounded sessions, while giving each spilling caller a fresh bounded
448/// memory pool so concurrent sorts cannot consume each other's headroom.
449pub fn get_session_context(options: &LanceExecutionOptions) -> SessionContext {
450    if options.use_spilling() {
451        return new_session_context(options);
452    }
453
454    let key = SessionContextCacheKey::from_options(options);
455    let mut cache = get_session_cache()
456        .lock()
457        .unwrap_or_else(|e| e.into_inner());
458
459    // If key exists, update access time and return
460    if let Some(entry) = cache.get_mut(&key) {
461        entry.last_access = std::time::Instant::now();
462        return entry.context.clone();
463    }
464
465    // Evict least recently used entry if cache is full
466    if cache.len() >= get_max_cache_size()
467        && let Some(lru_key) = cache
468            .iter()
469            .min_by_key(|(_, v)| v.last_access)
470            .map(|(k, _)| k.clone())
471    {
472        cache.remove(&lru_key);
473    }
474
475    let context = new_session_context(options);
476    cache.insert(
477        key,
478        CachedSessionContext {
479            context: context.clone(),
480            last_access: std::time::Instant::now(),
481        },
482    );
483    context
484}
485
486/// Returns a new task context to execute one plan with `options`.
487///
488/// Every execution needs its own context: Lance operators treat a task
489/// context's identity as the identity of one plan execution, for example to
490/// share a MultiMatch prefilter mask only between the fields of one query.
491fn get_task_context(
492    session_ctx: &SessionContext,
493    options: &LanceExecutionOptions,
494) -> Arc<TaskContext> {
495    // Build from the session state in place. `SessionContext::state` would clone
496    // the whole state (every function map, rule list and option) only to drop
497    // it, which is a measurable share of CPU for short queries.
498    let task_ctx = TaskContext::from(session_ctx);
499    let Some(batch_size) = options.batch_size else {
500        return Arc::new(task_ctx);
501    };
502    let mut session_config = task_ctx.session_config().clone();
503    session_config.options_mut().execution.batch_size = batch_size;
504    Arc::new(task_ctx.with_session_config(session_config))
505}
506
507#[derive(Default, Clone, Debug, PartialEq, Eq)]
508pub struct ExecutionSummaryCounts {
509    /// The number of I/O operations performed
510    pub iops: usize,
511    /// The number of requests made to the storage layer (may be larger or smaller than iops
512    /// depending on coalescing configuration)
513    pub requests: usize,
514    /// The number of bytes read during the execution of the plan
515    pub bytes_read: usize,
516    /// The number of top-level indices loaded
517    pub indices_loaded: usize,
518    /// The number of index partitions loaded
519    pub parts_loaded: usize,
520    /// The number of index comparisons performed (the exact meaning depends on the index type)
521    pub index_comparisons: usize,
522    /// Additional metrics for more detailed statistics.  These are subject to change in the future
523    /// and should only be used for debugging purposes.
524    ///
525    /// Newer metrics (e.g. [`INDEX_CACHE_HITS_METRIC`], [`INDEX_CACHE_MISSES_METRIC`]) are added
526    /// here rather than as `pub` fields, so this struct stays backwards compatible for callers
527    /// that construct or destructure it. Prefer the typed accessors below.
528    pub all_counts: HashMap<String, usize>,
529    /// Additional time metrics for more detailed statistics, stored in nanoseconds.
530    /// Operator baseline times use `<ExecutionPlan::name()>_elapsed_compute` keys.
531    /// Timings may be nested and accumulate across concurrent work; they are not
532    /// exclusive stages that can be added to recover query wall time.
533    /// These are subject to change in the future and should only be used for debugging purposes.
534    pub all_times: HashMap<String, usize>,
535}
536
537impl ExecutionSummaryCounts {
538    /// Number of index cache page lookups where the loader was not executed
539    /// (per-page granularity).
540    ///
541    /// A "hit" is any page-level lookup at an instrumented cache boundary that
542    /// did not run the loader on this call. That covers both a true cache hit
543    /// on an already-populated entry and a coalesced concurrent load where an
544    /// in-flight loader started by a different caller produced the value.
545    ///
546    /// Instrumented boundaries in this release:
547    /// BTree page, IVF partition (v2, `write_cache=true` scan path), inverted
548    /// posting list (grouped and per-token), inverted per-token metadata
549    /// (`PostingMetadataKey`), inverted phrase positions (`PositionKey`),
550    /// bitmap posting (Equals / Range / IsIn), ngram posting, and rtree page
551    /// / null slot.
552    ///
553    /// Caveats:
554    /// * IVF v2 streaming scans and legacy v1 IVF partitions run
555    ///   `load_partition` with `write_cache=false`. Those loads always execute
556    ///   the loader and never write the result back, so they are reported as a
557    ///   miss on every call. See [`Self::index_cache_hit_ratio`].
558    /// * A cold posting-list lookup on the grouped inverted layout can record
559    ///   up to two misses (posting-list group + per-token metadata) for a
560    ///   single term.
561    ///
562    /// Other index cache boundaries such as HNSW graph pages and quantizer
563    /// codebooks are not yet instrumented; a scan that only touches those
564    /// paths returns `0` here.
565    pub fn index_cache_hits(&self) -> usize {
566        self.all_counts
567            .get(INDEX_CACHE_HITS_METRIC)
568            .copied()
569            .unwrap_or(0)
570    }
571
572    /// Number of index cache page lookups that had to execute the loader
573    /// (per-page granularity).
574    ///
575    /// A "miss" is any page-level lookup at an instrumented cache boundary
576    /// where the loader ran, i.e. the page was not resident and had to be
577    /// materialised (typically from storage). See
578    /// [`Self::index_cache_hits`] for the paired counter and the list of
579    /// instrumented boundaries.
580    pub fn index_cache_misses(&self) -> usize {
581        self.all_counts
582            .get(INDEX_CACHE_MISSES_METRIC)
583            .copied()
584            .unwrap_or(0)
585    }
586
587    /// Ratio of index cache hits to total lookups. Returns `0.0` when no lookups
588    /// were recorded in this scan.
589    ///
590    /// This ratio only reflects paths that write their result back to the
591    /// index cache. Streaming scans (IVF v2 `write_cache=false` and legacy v1
592    /// IVF `load_partition_stream`) intentionally bypass the cache and are
593    /// counted as misses on every call, so a workload dominated by streaming
594    /// vector scans will report a hit ratio near `0.0` regardless of cache
595    /// size.
596    pub fn index_cache_hit_ratio(&self) -> f32 {
597        // Widen to u128 before summing so a pathological (hits + misses)
598        // overflow can't panic in debug builds nor wrap in release builds.
599        let hits = self.index_cache_hits() as u128;
600        let total = hits + self.index_cache_misses() as u128;
601        if total == 0 {
602            0.0
603        } else {
604            hits as f32 / total as f32
605        }
606    }
607}
608
609pub fn collect_execution_metrics(node: &dyn ExecutionPlan, counts: &mut ExecutionSummaryCounts) {
610    if let Some(metrics) = node.metrics() {
611        for (metric_name, count) in metrics.iter_counts() {
612            match metric_name.as_ref() {
613                IOPS_METRIC => counts.iops += count.value(),
614                REQUESTS_METRIC => counts.requests += count.value(),
615                BYTES_READ_METRIC => counts.bytes_read += count.value(),
616                INDICES_LOADED_METRIC => counts.indices_loaded += count.value(),
617                PARTS_LOADED_METRIC => counts.parts_loaded += count.value(),
618                INDEX_COMPARISONS_METRIC => counts.index_comparisons += count.value(),
619                _ => {
620                    let existing = counts
621                        .all_counts
622                        .entry(metric_name.as_ref().to_string())
623                        .or_insert(0);
624                    *existing += count.value();
625                }
626            }
627        }
628        for (metric_name, time) in metrics.iter_times() {
629            let existing = counts
630                .all_times
631                .entry(metric_name.as_ref().to_string())
632                .or_insert(0);
633            *existing += time.value();
634        }
635        // Keep operator baselines separate: ANN elapsed time includes asynchronous waits,
636        // while operators such as SortExec report compute time. Summing them hides the stages.
637        if let Some(elapsed) = metrics.elapsed_compute() {
638            *counts
639                .all_times
640                .entry(format!("{}_elapsed_compute", node.name()))
641                .or_default() += elapsed;
642        }
643        // Include gauge-based I/O metrics (some nodes record I/O as gauges)
644        for (metric_name, gauge) in metrics.iter_gauges() {
645            match metric_name.as_ref() {
646                IOPS_METRIC => counts.iops += gauge.value(),
647                REQUESTS_METRIC => counts.requests += gauge.value(),
648                BYTES_READ_METRIC => counts.bytes_read += gauge.value(),
649                _ => {}
650            }
651        }
652    }
653    for child in node.children() {
654        collect_execution_metrics(child.as_ref(), counts);
655    }
656}
657
658fn report_plan_summary_metrics(plan: &dyn ExecutionPlan, options: &LanceExecutionOptions) {
659    let output_rows = plan
660        .metrics()
661        .map(|m| m.output_rows().unwrap_or(0))
662        .unwrap_or(0);
663    let mut counts = ExecutionSummaryCounts::default();
664    collect_execution_metrics(plan, &mut counts);
665    if !options.skip_logging {
666        tracing::info!(
667            target: TRACE_EXECUTION,
668            r#type = EXECUTION_PLAN_RUN,
669            plan_summary = display_plan_one_liner(plan),
670            output_rows,
671            iops = counts.iops,
672            requests = counts.requests,
673            bytes_read = counts.bytes_read,
674            indices_loaded = counts.indices_loaded,
675            parts_loaded = counts.parts_loaded,
676            index_comparisons = counts.index_comparisons,
677            index_cache_hits = counts.index_cache_hits(),
678            index_cache_misses = counts.index_cache_misses(),
679        );
680    }
681    if let Some(callback) = options.execution_stats_callback.as_ref() {
682        callback(&counts);
683    }
684}
685
686/// Create a one-line rough summary of the given execution plan.
687///
688/// The summary just shows the name of the operators in the plan. It omits any
689/// details such as parameters or schema information.
690///
691/// Example: `Projection(Take(CoalesceBatches(Filter(LanceScan))))`
692fn display_plan_one_liner(plan: &dyn ExecutionPlan) -> String {
693    let mut output = String::new();
694
695    display_plan_one_liner_impl(plan, &mut output);
696
697    output
698}
699
700fn display_plan_one_liner_impl(plan: &dyn ExecutionPlan, output: &mut String) {
701    // Remove the "Exec" suffix from the plan name if present for brevity
702    let name = plan.name().trim_end_matches("Exec");
703    output.push_str(name);
704
705    let children = plan.children();
706    if !children.is_empty() {
707        output.push('(');
708        for (i, child) in children.iter().enumerate() {
709            if i > 0 {
710                output.push(',');
711            }
712            display_plan_one_liner_impl(child.as_ref(), output);
713        }
714        output.push(')');
715    }
716}
717
718/// Executes a plan using default session & runtime configuration
719///
720/// Only executes a single partition.  Panics if the plan has more than one partition.
721pub fn execute_plan(
722    plan: Arc<dyn ExecutionPlan>,
723    options: LanceExecutionOptions,
724) -> Result<SendableRecordBatchStream> {
725    if !options.skip_logging {
726        debug!(
727            "Executing plan:\n{}",
728            DisplayableExecutionPlan::new(plan.as_ref()).indent(true)
729        );
730    }
731
732    let session_ctx = get_session_context(&options);
733
734    // Coalesce to a single partition if the optimizer left more than one.
735    // EnforceDistribution may remove RepartitionExec(1) nodes when the parent
736    // declares UnspecifiedDistribution, leaving multi-partition plans here.
737    //
738    // If the plan carries an output ordering (e.g. a top-k `SortExec` whose
739    // result was later repartitioned to parallelize downstream operators),
740    // a plain `CoalescePartitionsExec` would scramble that order because it
741    // merges partitions in scheduling-dependent order. Use an order-preserving
742    // merge in that case instead, mirroring what `EnforceDistribution` itself
743    // does when it needs to merge an ordered, multi-partition plan.
744    let plan: Arc<dyn ExecutionPlan> = if plan.properties().partitioning.partition_count() == 1 {
745        plan
746    } else if let Some(ordering) = plan.output_ordering() {
747        Arc::new(SortPreservingMergeExec::new(ordering.clone(), plan))
748    } else {
749        Arc::new(CoalescePartitionsExec::new(plan))
750    };
751
752    let stream = plan.execute(0, get_task_context(&session_ctx, &options))?;
753
754    let schema = stream.schema();
755    let stream = stream.finally(move || {
756        if !options.skip_logging || options.execution_stats_callback.is_some() {
757            report_plan_summary_metrics(plan.as_ref(), &options);
758        }
759    });
760    Ok(Box::pin(RecordBatchStreamAdapter::new(schema, stream)))
761}
762
763pub async fn analyze_plan(
764    plan: Arc<dyn ExecutionPlan>,
765    options: LanceExecutionOptions,
766) -> Result<String> {
767    analyze_plan_with_context(plan, options, None).await
768}
769
770/// Analyze a plan, optionally under a caller-provided [`TaskContext`].
771///
772/// When `task_context` is `Some`, the plan executes under it instead of the
773/// context derived from `options`. Callers whose nodes read session-config
774/// extensions at execution time (e.g. distributed routing identity) must pass
775/// the context carrying those extensions; otherwise the nodes error during
776/// `execute` and `AnalyzeExec` reports an empty, unexecuted plan tree instead
777/// of surfacing the error.
778pub async fn analyze_plan_with_context(
779    plan: Arc<dyn ExecutionPlan>,
780    options: LanceExecutionOptions,
781    task_context: Option<Arc<TaskContext>>,
782) -> Result<String> {
783    // This is needed as AnalyzeExec launches a thread task per
784    // partition, and we want these to be connected to the parent span
785    let plan = Arc::new(TracedExec::new(plan, Span::current()));
786
787    let schema = plan.schema();
788    // TODO(tsaucer) I chose SUMMARY here but do we also want DEV?
789    let analyze = Arc::new(AnalyzeExec::new(
790        true,
791        true,
792        vec![MetricType::Summary],
793        None,
794        plan,
795        schema,
796    ));
797
798    let session_ctx = get_session_context(&options);
799    let task_context = task_context.unwrap_or_else(|| get_task_context(&session_ctx, &options));
800    assert_eq!(analyze.properties().partitioning.partition_count(), 1);
801    let mut stream = analyze
802        .execute(0, task_context)
803        .map_err(|err| Error::io(format!("Failed to execute analyze plan: {}", err)))?;
804
805    // fully execute the plan
806    while (stream.next().await).is_some() {}
807
808    let result = format_plan(analyze);
809    Ok(result)
810}
811
812pub fn format_plan(plan: Arc<dyn ExecutionPlan>) -> String {
813    /// A visitor which calculates additional metrics for all the plans.
814    struct CalculateVisitor {
815        highest_index: usize,
816        index_to_elapsed: HashMap<usize, Duration>,
817    }
818
819    /// Result of calculating metrics for a subtree
820    struct SubtreeMetrics {
821        min_start: Option<DateTime<Utc>>,
822        max_end: Option<DateTime<Utc>>,
823    }
824
825    impl CalculateVisitor {
826        fn calculate_metrics(&mut self, plan: &Arc<dyn ExecutionPlan>) -> SubtreeMetrics {
827            self.highest_index += 1;
828            let plan_index = self.highest_index;
829
830            // Get timestamps for this node
831            let (mut min_start, mut max_end) = Self::node_timerange(plan);
832
833            // Accumulate from children
834            for child in plan.children() {
835                let child_metrics = self.calculate_metrics(child);
836                min_start = Self::min_option(min_start, child_metrics.min_start);
837                max_end = Self::max_option(max_end, child_metrics.max_end);
838            }
839
840            // Calculate wall clock duration for this subtree (only if we have timestamps)
841            let elapsed = match (min_start, max_end) {
842                (Some(start), Some(end)) => Some((end - start).to_std().unwrap_or_default()),
843                _ => None,
844            };
845
846            if let Some(e) = elapsed {
847                self.index_to_elapsed.insert(plan_index, e);
848            }
849
850            SubtreeMetrics { min_start, max_end }
851        }
852
853        fn node_timerange(
854            plan: &Arc<dyn ExecutionPlan>,
855        ) -> (Option<DateTime<Utc>>, Option<DateTime<Utc>>) {
856            let Some(metrics) = plan.metrics() else {
857                return (None, None);
858            };
859            let min_start = metrics
860                .iter()
861                .filter_map(|m| match m.value() {
862                    MetricValue::StartTimestamp(ts) => ts.value(),
863                    _ => None,
864                })
865                .min();
866            let max_end = metrics
867                .iter()
868                .filter_map(|m| match m.value() {
869                    MetricValue::EndTimestamp(ts) => ts.value(),
870                    _ => None,
871                })
872                .max();
873            (min_start, max_end)
874        }
875
876        fn min_option(a: Option<DateTime<Utc>>, b: Option<DateTime<Utc>>) -> Option<DateTime<Utc>> {
877            [a, b].into_iter().flatten().min()
878        }
879
880        fn max_option(a: Option<DateTime<Utc>>, b: Option<DateTime<Utc>>) -> Option<DateTime<Utc>> {
881            [a, b].into_iter().flatten().max()
882        }
883    }
884
885    /// A visitor which prints out all the plans.
886    struct PrintVisitor {
887        highest_index: usize,
888        indent: usize,
889    }
890    impl PrintVisitor {
891        fn write_output(
892            &mut self,
893            plan: &Arc<dyn ExecutionPlan>,
894            f: &mut Formatter,
895            calcs: &CalculateVisitor,
896        ) -> std::fmt::Result {
897            self.highest_index += 1;
898            write!(f, "{:indent$}", "", indent = self.indent * 2)?;
899
900            // Format the plan description
901            let displayable =
902                datafusion::physical_plan::display::DisplayableExecutionPlan::new(plan.as_ref());
903            let plan_str = displayable.one_line().to_string();
904            let plan_str = plan_str.trim();
905
906            // Write operator with elapsed time inserted after the name
907            match calcs.index_to_elapsed.get(&self.highest_index) {
908                Some(elapsed) => match plan_str.find(": ") {
909                    Some(i) => write!(
910                        f,
911                        "{}: elapsed={elapsed:?}, {}",
912                        &plan_str[..i],
913                        &plan_str[i + 2..]
914                    )?,
915                    None => write!(f, "{plan_str}, elapsed={elapsed:?}")?,
916                },
917                None => write!(f, "{plan_str}")?,
918            }
919
920            if let Some(metrics) = plan.metrics() {
921                let metrics = metrics
922                    .aggregate_by_name()
923                    .sorted_for_display()
924                    .timestamps_removed();
925
926                write!(f, ", metrics=[{metrics}]")?;
927            } else {
928                write!(f, ", metrics=[]")?;
929            }
930            writeln!(f)?;
931            self.indent += 1;
932            for child in plan.children() {
933                self.write_output(child, f, calcs)?;
934            }
935            self.indent -= 1;
936            std::fmt::Result::Ok(())
937        }
938    }
939    // A wrapper which prints out a plan.
940    struct PrintWrapper {
941        plan: Arc<dyn ExecutionPlan>,
942    }
943    impl fmt::Display for PrintWrapper {
944        fn fmt(&self, f: &mut Formatter) -> std::fmt::Result {
945            let mut calcs = CalculateVisitor {
946                highest_index: 0,
947                index_to_elapsed: HashMap::new(),
948            };
949            calcs.calculate_metrics(&self.plan);
950            let mut prints = PrintVisitor {
951                highest_index: 0,
952                indent: 0,
953            };
954            prints.write_output(&self.plan, f, &calcs)
955        }
956    }
957    let wrapper = PrintWrapper { plan };
958    format!("{}", wrapper)
959}
960
961pub trait SessionContextExt {
962    /// Creates a DataFrame for reading a stream of data
963    ///
964    /// This dataframe may only be queried once, future queries will fail
965    fn read_one_shot(
966        &self,
967        data: SendableRecordBatchStream,
968    ) -> datafusion::common::Result<DataFrame>;
969}
970
971pub struct OneShotPartitionStream {
972    data: Arc<Mutex<Option<SendableRecordBatchStream>>>,
973    schema: Arc<ArrowSchema>,
974}
975
976impl std::fmt::Debug for OneShotPartitionStream {
977    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
978        let data = self.data.lock().unwrap();
979        f.debug_struct("OneShotPartitionStream")
980            .field("exhausted", &data.is_none())
981            .field("schema", self.schema.as_ref())
982            .finish()
983    }
984}
985
986impl OneShotPartitionStream {
987    pub fn new(data: SendableRecordBatchStream) -> Self {
988        let schema = data.schema();
989        Self {
990            data: Arc::new(Mutex::new(Some(data))),
991            schema,
992        }
993    }
994}
995
996impl PartitionStream for OneShotPartitionStream {
997    fn schema(&self) -> &arrow_schema::SchemaRef {
998        &self.schema
999    }
1000
1001    fn execute(&self, _ctx: Arc<TaskContext>) -> SendableRecordBatchStream {
1002        let mut stream = self.data.lock().unwrap();
1003        stream
1004            .take()
1005            .expect("Attempt to consume a one shot dataframe multiple times")
1006    }
1007}
1008
1009impl SessionContextExt for SessionContext {
1010    fn read_one_shot(
1011        &self,
1012        data: SendableRecordBatchStream,
1013    ) -> datafusion::common::Result<DataFrame> {
1014        let schema = data.schema();
1015        let part_stream = Arc::new(OneShotPartitionStream::new(data));
1016        let provider = StreamingTable::try_new(schema, vec![part_stream])?;
1017        self.read_table(Arc::new(provider))
1018    }
1019}
1020
1021/// Scan a [`TableProvider`] into a single-partition [`SendableRecordBatchStream`].
1022///
1023/// Multi-partition providers are coalesced into a single partition. This adapts a
1024/// re-scannable provider back into the one stream the writer pipeline consumes;
1025/// re-scanning the same provider (e.g. on a write retry) yields a fresh stream.
1026///
1027/// # Examples
1028///
1029/// ```
1030/// # use std::sync::Arc;
1031/// # use arrow_array::{Int32Array, RecordBatch};
1032/// # use arrow_schema::{DataType, Field, Schema};
1033/// # use datafusion::catalog::TableProvider;
1034/// # use datafusion::datasource::MemTable;
1035/// # use futures::TryStreamExt;
1036/// # use lance_datafusion::exec::provider_to_stream;
1037/// # #[tokio::main]
1038/// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
1039/// let schema = Arc::new(Schema::new(vec![Field::new("a", DataType::Int32, false)]));
1040/// let batch =
1041///     RecordBatch::try_new(schema.clone(), vec![Arc::new(Int32Array::from(vec![1, 2, 3]))])?;
1042/// let provider: Arc<dyn TableProvider> = Arc::new(MemTable::try_new(schema, vec![vec![batch]])?);
1043///
1044/// // A re-scannable provider yields a fresh stream on each call.
1045/// let batches: Vec<RecordBatch> = provider_to_stream(provider).await?.try_collect().await?;
1046/// assert_eq!(batches.iter().map(|b| b.num_rows()).sum::<usize>(), 3);
1047/// # Ok(())
1048/// # }
1049/// ```
1050pub async fn provider_to_stream(
1051    provider: Arc<dyn TableProvider>,
1052) -> Result<SendableRecordBatchStream> {
1053    let ctx = SessionContext::new();
1054    let plan = provider.scan(&ctx.state(), None, &[], None).await?;
1055    let plan: Arc<dyn ExecutionPlan> =
1056        if plan.properties().output_partitioning().partition_count() > 1 {
1057            Arc::new(CoalescePartitionsExec::new(plan))
1058        } else {
1059            plan
1060        };
1061    Ok(plan.execute(0, ctx.task_ctx())?)
1062}
1063
1064#[derive(Clone, Debug)]
1065pub struct StrictBatchSizeExec {
1066    input: Arc<dyn ExecutionPlan>,
1067    batch_size: usize,
1068}
1069
1070impl StrictBatchSizeExec {
1071    pub fn new(input: Arc<dyn ExecutionPlan>, batch_size: usize) -> Self {
1072        Self { input, batch_size }
1073    }
1074}
1075
1076impl DisplayAs for StrictBatchSizeExec {
1077    fn fmt_as(
1078        &self,
1079        _t: datafusion::physical_plan::DisplayFormatType,
1080        f: &mut std::fmt::Formatter,
1081    ) -> std::fmt::Result {
1082        write!(f, "StrictBatchSizeExec")
1083    }
1084}
1085
1086impl ExecutionPlan for StrictBatchSizeExec {
1087    fn name(&self) -> &str {
1088        "StrictBatchSizeExec"
1089    }
1090
1091    fn properties(&self) -> &Arc<PlanProperties> {
1092        self.input.properties()
1093    }
1094
1095    fn children(&self) -> Vec<&Arc<dyn ExecutionPlan>> {
1096        vec![&self.input]
1097    }
1098
1099    fn with_new_children(
1100        self: Arc<Self>,
1101        children: Vec<Arc<dyn ExecutionPlan>>,
1102    ) -> datafusion_common::Result<Arc<dyn ExecutionPlan>> {
1103        Ok(Arc::new(Self {
1104            input: children[0].clone(),
1105            batch_size: self.batch_size,
1106        }))
1107    }
1108
1109    fn execute(
1110        &self,
1111        partition: usize,
1112        context: Arc<TaskContext>,
1113    ) -> datafusion_common::Result<SendableRecordBatchStream> {
1114        let stream = self.input.execute(partition, context)?;
1115        let schema = stream.schema();
1116        let stream = StrictBatchSizeStream::new(stream, self.batch_size);
1117        Ok(Box::pin(RecordBatchStreamAdapter::new(schema, stream)))
1118    }
1119
1120    fn maintains_input_order(&self) -> Vec<bool> {
1121        vec![true]
1122    }
1123
1124    fn benefits_from_input_partitioning(&self) -> Vec<bool> {
1125        vec![false]
1126    }
1127
1128    fn partition_statistics(
1129        &self,
1130        partition: Option<usize>,
1131    ) -> datafusion_common::Result<std::sync::Arc<Statistics>> {
1132        self.input.partition_statistics(partition)
1133    }
1134
1135    fn cardinality_effect(&self) -> CardinalityEffect {
1136        CardinalityEffect::Equal
1137    }
1138
1139    fn supports_limit_pushdown(&self) -> bool {
1140        true
1141    }
1142}
1143
1144/// Exec node that rechunks batches so no output batch exceeds `max_bytes`.
1145///
1146/// # Why this exists
1147///
1148/// DataFusion's sort operator must reserve memory for an entire input batch,
1149/// including estimated sort/merge overhead. It can spill buffered batches and
1150/// retry, but still fails if the new batch's reservation cannot fit. This is
1151/// separate from the historical allocation issues fixed by DataFusion PR #14644
1152/// (<https://github.com/apache/datafusion/pull/14644>).
1153///
1154/// This node bounds input batch sizes before sorting, at the cost of potentially
1155/// expensive deep copies. It does not guarantee success for every memory pool:
1156/// spill/merge reservations and other consumers also need room in the pool.
1157///
1158/// # Deep copy
1159///
1160/// After slicing a RecordBatch, `get_array_memory_size` still reports the
1161/// size of the *original* backing buffers, not the slice.  To get accurate
1162/// sizes the slices must be deep-copied.  This is a last resort and can be
1163/// expensive for large batches, but the deep copy is only performed when a
1164/// batch actually needs to be sliced — batches that are already within the
1165/// target range pass through at zero cost.
1166///
1167/// If a single row exceeds `max_bytes`, execution fails with an error.
1168#[derive(Clone, Debug)]
1169pub struct HardCapBatchSizeExec {
1170    input: Arc<dyn ExecutionPlan>,
1171    max_bytes: usize,
1172}
1173
1174impl HardCapBatchSizeExec {
1175    pub fn new(input: Arc<dyn ExecutionPlan>, max_bytes: usize) -> Self {
1176        Self { input, max_bytes }
1177    }
1178}
1179
1180impl DisplayAs for HardCapBatchSizeExec {
1181    fn fmt_as(
1182        &self,
1183        _t: datafusion::physical_plan::DisplayFormatType,
1184        f: &mut std::fmt::Formatter,
1185    ) -> std::fmt::Result {
1186        write!(f, "HardCapBatchSizeExec(max_bytes={})", self.max_bytes)
1187    }
1188}
1189
1190impl ExecutionPlan for HardCapBatchSizeExec {
1191    fn name(&self) -> &str {
1192        "HardCapBatchSizeExec"
1193    }
1194
1195    fn properties(&self) -> &Arc<PlanProperties> {
1196        self.input.properties()
1197    }
1198
1199    fn children(&self) -> Vec<&Arc<dyn ExecutionPlan>> {
1200        vec![&self.input]
1201    }
1202
1203    fn with_new_children(
1204        self: Arc<Self>,
1205        children: Vec<Arc<dyn ExecutionPlan>>,
1206    ) -> datafusion_common::Result<Arc<dyn ExecutionPlan>> {
1207        Ok(Arc::new(Self {
1208            input: children[0].clone(),
1209            max_bytes: self.max_bytes,
1210        }))
1211    }
1212
1213    fn execute(
1214        &self,
1215        partition: usize,
1216        context: Arc<TaskContext>,
1217    ) -> datafusion_common::Result<SendableRecordBatchStream> {
1218        let stream = self.input.execute(partition, context)?;
1219        let schema = stream.schema();
1220        let max_bytes = self.max_bytes;
1221        let rechunked = lance_arrow::stream::rechunk_stream_by_size_deep_copy(
1222            stream,
1223            schema.clone(),
1224            0,
1225            max_bytes,
1226        );
1227        // Check that no single-row batch exceeds the limit.
1228        let validated = rechunked.map(move |result| {
1229            let batch = result?;
1230            if batch.num_rows() == 1 && batch.get_array_memory_size() > max_bytes {
1231                return Err(DataFusionError::External(Box::new(Error::invalid_input(
1232                    format!(
1233                        "a single row is {} bytes which exceeds the maximum allowed batch \
1234                         size of {} bytes",
1235                        batch.get_array_memory_size(),
1236                        max_bytes,
1237                    ),
1238                ))));
1239            }
1240            Ok(batch)
1241        });
1242        Ok(Box::pin(RecordBatchStreamAdapter::new(schema, validated)))
1243    }
1244
1245    fn maintains_input_order(&self) -> Vec<bool> {
1246        vec![true]
1247    }
1248
1249    fn benefits_from_input_partitioning(&self) -> Vec<bool> {
1250        vec![false]
1251    }
1252
1253    fn partition_statistics(
1254        &self,
1255        partition: Option<usize>,
1256    ) -> datafusion_common::Result<std::sync::Arc<Statistics>> {
1257        self.input.partition_statistics(partition)
1258    }
1259
1260    fn cardinality_effect(&self) -> CardinalityEffect {
1261        CardinalityEffect::Equal
1262    }
1263
1264    fn supports_limit_pushdown(&self) -> bool {
1265        true
1266    }
1267}
1268
1269#[cfg(test)]
1270mod tests {
1271    use super::*;
1272    use datafusion::execution::memory_pool::MemoryConsumer;
1273
1274    use arrow_array::{Int32Array, cast::AsArray, record_batch, types::Int32Type};
1275    use arrow_select::concat::concat_batches;
1276    use datafusion::execution::memory_pool::MemoryLimit;
1277    use datafusion::physical_plan::sorts::sort::SortExec;
1278    use datafusion_physical_expr::{LexOrdering, PhysicalSortExpr, expressions::col};
1279    use futures::TryStreamExt;
1280    use rstest::rstest;
1281
1282    // Serialize cache tests since they share global state
1283    static CACHE_TEST_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
1284
1285    #[test]
1286    fn test_session_context_cache() {
1287        let _lock = CACHE_TEST_LOCK.lock().unwrap();
1288        let cache = get_session_cache();
1289
1290        // Clear any existing entries from other tests
1291        cache.lock().unwrap().clear();
1292
1293        // Create first session with default options
1294        let opts1 = LanceExecutionOptions::default();
1295        let _ctx1 = get_session_context(&opts1);
1296
1297        {
1298            let cache_guard = cache.lock().unwrap();
1299            assert_eq!(cache_guard.len(), 1);
1300        }
1301
1302        // Same options should reuse cached session (no new entry)
1303        let _ctx1_again = get_session_context(&opts1);
1304        {
1305            let cache_guard = cache.lock().unwrap();
1306            assert_eq!(cache_guard.len(), 1);
1307        }
1308
1309        // Different non-spilling options should create a new entry.
1310        let opts2 = LanceExecutionOptions {
1311            target_partition: Some(1),
1312            ..Default::default()
1313        };
1314        let _ctx2 = get_session_context(&opts2);
1315        {
1316            let cache_guard = cache.lock().unwrap();
1317            assert_eq!(cache_guard.len(), 2);
1318        }
1319    }
1320
1321    #[test]
1322    fn test_spilling_executions_have_independent_memory_pools() {
1323        let options = LanceExecutionOptions {
1324            use_spilling: true,
1325            mem_pool_size: Some(DEFAULT_LANCE_MEM_POOL_SIZE_PER_PARTITION),
1326            target_partition: Some(1),
1327            ..Default::default()
1328        };
1329        let contexts: Vec<_> = (0..3).map(|_| get_session_context(&options)).collect();
1330        let mut reservations = Vec::with_capacity(contexts.len());
1331
1332        // Each reservation fits its own pool, but any two exceed a shared pool.
1333        for context in &contexts {
1334            let task_context = context.task_ctx();
1335            let pool = task_context.memory_pool();
1336            let reservation = MemoryConsumer::new("ExternalSorterMerge[0]").register(pool);
1337            reservation.try_grow(100 * 1024 * 1024).unwrap();
1338            assert_eq!(pool.reserved(), 100 * 1024 * 1024);
1339            reservations.push(reservation);
1340        }
1341
1342        assert_eq!(reservations.len(), 3);
1343    }
1344
1345    #[test]
1346    fn test_session_context_cache_lru_eviction() {
1347        let _lock = CACHE_TEST_LOCK.lock().unwrap();
1348        let cache = get_session_cache();
1349
1350        // Clear any existing entries from other tests
1351        cache.lock().unwrap().clear();
1352
1353        // Create 4 different configurations to fill the cache
1354        let configs: Vec<LanceExecutionOptions> = (0..4)
1355            .map(|i| LanceExecutionOptions {
1356                mem_pool_size: Some((i + 1) as u64 * 1024 * 1024),
1357                ..Default::default()
1358            })
1359            .collect();
1360
1361        for config in &configs {
1362            let _ctx = get_session_context(config);
1363        }
1364
1365        {
1366            let cache_guard = cache.lock().unwrap();
1367            assert_eq!(cache_guard.len(), 4);
1368        }
1369
1370        // Access config[0] to make it more recently used than config[1]
1371        // (config[0] was inserted first, so without this access it would be evicted)
1372        std::thread::sleep(std::time::Duration::from_millis(1));
1373        let _ctx = get_session_context(&configs[0]);
1374
1375        // Add a 5th configuration - should evict config[1] (now least recently used)
1376        let opts5 = LanceExecutionOptions {
1377            mem_pool_size: Some(5 * 1024 * 1024),
1378            ..Default::default()
1379        };
1380        let _ctx5 = get_session_context(&opts5);
1381
1382        {
1383            let cache_guard = cache.lock().unwrap();
1384            assert_eq!(cache_guard.len(), 4);
1385
1386            // config[0] should still be present (was accessed recently)
1387            let key0 = SessionContextCacheKey::from_options(&configs[0]);
1388            assert!(
1389                cache_guard.contains_key(&key0),
1390                "config[0] should still be cached after recent access"
1391            );
1392
1393            // config[1] should be evicted (was least recently used)
1394            let key1 = SessionContextCacheKey::from_options(&configs[1]);
1395            assert!(
1396                !cache_guard.contains_key(&key1),
1397                "config[1] should have been evicted"
1398            );
1399
1400            // New config should be present
1401            let key5 = SessionContextCacheKey::from_options(&opts5);
1402            assert!(
1403                cache_guard.contains_key(&key5),
1404                "new config should be cached"
1405            );
1406        }
1407    }
1408
1409    #[test]
1410    fn test_task_context_per_execution() {
1411        let _lock = CACHE_TEST_LOCK.lock().unwrap();
1412
1413        let default_options = LanceExecutionOptions::default();
1414        let session_ctx = get_session_context(&default_options);
1415        let task_ctx = get_task_context(&session_ctx, &default_options);
1416        // Lance operators key per-execution state on the task context's identity.
1417        assert!(!Arc::ptr_eq(
1418            &task_ctx,
1419            &get_task_context(&session_ctx, &default_options)
1420        ));
1421        assert_eq!(task_ctx.session_id(), session_ctx.session_id());
1422        assert_eq!(
1423            task_ctx.session_config().batch_size(),
1424            session_ctx.copied_config().batch_size()
1425        );
1426        assert!(Arc::ptr_eq(
1427            &task_ctx.runtime_env(),
1428            &session_ctx.runtime_env()
1429        ));
1430        assert!(task_ctx.scalar_functions().contains_key("contains_tokens"));
1431
1432        let spill_options = LanceExecutionOptions {
1433            use_spilling: true,
1434            mem_pool_size: Some(64 * 1024 * 1024),
1435            target_partition: Some(3),
1436            batch_size: Some(17),
1437            ..Default::default()
1438        };
1439        let spill_session_ctx = get_session_context(&spill_options);
1440        let spill_task_ctx = get_task_context(&spill_session_ctx, &spill_options);
1441        assert_eq!(spill_task_ctx.session_config().batch_size(), 17);
1442        assert_eq!(spill_task_ctx.session_config().target_partitions(), 3);
1443        assert!(
1444            spill_task_ctx
1445                .scalar_functions()
1446                .contains_key("contains_tokens")
1447        );
1448        // The batch size override applies to this execution only, and the
1449        // execution still shares the session's memory pool.
1450        assert_ne!(spill_session_ctx.copied_config().batch_size(), 17);
1451        assert!(Arc::ptr_eq(
1452            &spill_task_ctx.runtime_env(),
1453            &spill_session_ctx.runtime_env()
1454        ));
1455        assert!(matches!(
1456            spill_task_ctx.memory_pool().memory_limit(),
1457            MemoryLimit::Finite(limit) if limit == 64 * 1024 * 1024
1458        ));
1459    }
1460
1461    #[rstest]
1462    #[case::session_batch_size(None, &[6])]
1463    #[case::batch_size_override(Some(4), &[4, 2])]
1464    #[tokio::test]
1465    async fn test_execute_plan_batch_size(
1466        #[case] batch_size: Option<usize>,
1467        #[case] expected_batch_rows: &[usize],
1468    ) {
1469        let batch = record_batch!(("x", Int32, [5, 3, 1, 4, 2, 0])).unwrap();
1470        let sort_expr = PhysicalSortExpr::new_default(col("x", &batch.schema()).unwrap());
1471        let plan = Arc::new(SortExec::new(
1472            LexOrdering::new([sort_expr]).unwrap(),
1473            Arc::new(OneShotExec::from_batch(batch.clone())),
1474        ));
1475        let options = LanceExecutionOptions {
1476            batch_size,
1477            ..Default::default()
1478        };
1479        let batches: Vec<RecordBatch> = execute_plan(plan, options)
1480            .unwrap()
1481            .try_collect()
1482            .await
1483            .unwrap();
1484        let batch_rows: Vec<usize> = batches.iter().map(RecordBatch::num_rows).collect();
1485        assert_eq!(batch_rows, expected_batch_rows);
1486        let sorted = concat_batches(&batch.schema(), &batches).unwrap();
1487        assert_eq!(
1488            sorted["x"].as_primitive::<Int32Type>(),
1489            &Int32Array::from(vec![0, 1, 2, 3, 4, 5])
1490        );
1491    }
1492
1493    #[test]
1494    fn test_mem_pool_size_scales_with_partitions() {
1495        let default_per_partition = DEFAULT_LANCE_MEM_POOL_SIZE_PER_PARTITION;
1496
1497        // No partitions specified → the session runs with DataFusion's default
1498        // target_partitions (available parallelism), so the pool must be sized
1499        // for that effective partition count.
1500        let opts = LanceExecutionOptions::default();
1501        assert_eq!(
1502            opts.mem_pool_size(),
1503            default_per_partition * get_available_parallelism() as u64
1504        );
1505
1506        // 4 partitions → 4x the per-partition size
1507        let opts = LanceExecutionOptions {
1508            target_partition: Some(4),
1509            ..Default::default()
1510        };
1511        assert_eq!(opts.mem_pool_size(), default_per_partition * 4);
1512
1513        // 8 partitions → 8x the per-partition size
1514        let opts = LanceExecutionOptions {
1515            target_partition: Some(8),
1516            ..Default::default()
1517        };
1518        assert_eq!(opts.mem_pool_size(), default_per_partition * 8);
1519
1520        // Explicit mem_pool_size is not scaled
1521        let opts = LanceExecutionOptions {
1522            mem_pool_size: Some(50 * 1024 * 1024),
1523            target_partition: Some(8),
1524            ..Default::default()
1525        };
1526        assert_eq!(opts.mem_pool_size(), 50 * 1024 * 1024);
1527    }
1528
1529    #[test]
1530    fn test_default_pool_fits_sort_merge_reservations_per_partition() {
1531        let options = LanceExecutionOptions {
1532            use_spilling: true,
1533            ..Default::default()
1534        };
1535        let num_partitions = options.effective_target_partition() as usize;
1536        let session_ctx = new_session_context(&options);
1537        let task_ctx = session_ctx.task_ctx();
1538        let pool = task_ctx.memory_pool();
1539
1540        // External sort merges cannot spill their own reservations. Keep three
1541        // merge reservations per execution partition while leaving 30 MiB per
1542        // partition for input batches and other operators. Before the default
1543        // pool followed the effective partition count, the fourth reservation
1544        // exhausted the single 150 MiB pool on machines with multiple cores.
1545        let num_reservations = num_partitions * 3;
1546        let mut reservations = Vec::with_capacity(num_reservations);
1547        for _ in 0..num_reservations {
1548            let reservation = MemoryConsumer::new("ExternalSorterMerge[0]").register(pool);
1549            reservation
1550                .try_grow(MAX_SORT_SPILL_RESERVATION_BYTES as usize)
1551                .unwrap();
1552            reservations.push(reservation);
1553        }
1554
1555        assert_eq!(
1556            pool.reserved(),
1557            num_reservations * MAX_SORT_SPILL_RESERVATION_BYTES as usize
1558        );
1559    }
1560
1561    /// A marker a node reads from the session-config extensions at execute time.
1562    #[derive(Debug)]
1563    struct RequiredExtension;
1564
1565    /// Execution node that only succeeds when [`RequiredExtension`] is present
1566    /// on the task context's session config. This mirrors distributed routing
1567    /// nodes that read a session-config identity extension during `execute`.
1568    #[derive(Debug)]
1569    struct NeedsExtensionExec {
1570        properties: Arc<PlanProperties>,
1571        /// Set once the node reaches execution with the extension present.
1572        /// Observed by the test so that dropping context forwarding (which
1573        /// makes `execute` error before this point) is detectable.
1574        executed: Arc<std::sync::atomic::AtomicBool>,
1575    }
1576
1577    impl NeedsExtensionExec {
1578        fn new(executed: Arc<std::sync::atomic::AtomicBool>) -> Self {
1579            let schema = Arc::new(ArrowSchema::empty());
1580            Self {
1581                properties: Arc::new(PlanProperties::new(
1582                    EquivalenceProperties::new(schema),
1583                    Partitioning::UnknownPartitioning(1),
1584                    EmissionType::Incremental,
1585                    Boundedness::Bounded,
1586                )),
1587                executed,
1588            }
1589        }
1590    }
1591
1592    impl DisplayAs for NeedsExtensionExec {
1593        fn fmt_as(&self, _t: DisplayFormatType, f: &mut Formatter) -> fmt::Result {
1594            write!(f, "NeedsExtensionExec")
1595        }
1596    }
1597
1598    impl ExecutionPlan for NeedsExtensionExec {
1599        fn name(&self) -> &str {
1600            "NeedsExtensionExec"
1601        }
1602        fn properties(&self) -> &Arc<PlanProperties> {
1603            &self.properties
1604        }
1605        fn children(&self) -> Vec<&Arc<dyn ExecutionPlan>> {
1606            vec![]
1607        }
1608        fn with_new_children(
1609            self: Arc<Self>,
1610            _children: Vec<Arc<dyn ExecutionPlan>>,
1611        ) -> datafusion_common::Result<Arc<dyn ExecutionPlan>> {
1612            Ok(self)
1613        }
1614        fn execute(
1615            &self,
1616            _partition: usize,
1617            context: Arc<TaskContext>,
1618        ) -> datafusion_common::Result<SendableRecordBatchStream> {
1619            if context
1620                .session_config()
1621                .get_extension::<RequiredExtension>()
1622                .is_none()
1623            {
1624                return Err(DataFusionError::Execution(
1625                    "missing required session-config extension".to_string(),
1626                ));
1627            }
1628            self.executed
1629                .store(true, std::sync::atomic::Ordering::SeqCst);
1630            let schema = self.schema();
1631            Ok(Box::pin(RecordBatchStreamAdapter::new(
1632                schema,
1633                stream::empty(),
1634            )))
1635        }
1636    }
1637
1638    // Regression: analyze must run under a caller-provided TaskContext so nodes
1639    // that read a session-config extension at execute time see it. Without the
1640    // context the node errors and AnalyzeExec would otherwise report an empty,
1641    // unexecuted plan tree.
1642    #[tokio::test]
1643    async fn test_analyze_plan_uses_provided_task_context() {
1644        use std::sync::atomic::{AtomicBool, Ordering};
1645
1646        let executed = Arc::new(AtomicBool::new(false));
1647        let plan: Arc<dyn ExecutionPlan> = Arc::new(NeedsExtensionExec::new(executed.clone()));
1648
1649        // Default context lacks the extension: the node errors during execute
1650        // (never reaching the `executed` flag), but AnalyzeExec absorbs that
1651        // per-partition failure and reports an empty, unexecuted plan tree
1652        // rather than propagating the error. This is the regression symptom.
1653        let report = analyze_plan(plan.clone(), LanceExecutionOptions::default())
1654            .await
1655            .expect("AnalyzeExec swallows the node's execute error into an Ok report");
1656        assert!(
1657            report.contains("NeedsExtensionExec, metrics=[]"),
1658            "expected an empty, unexecuted NeedsExtensionExec node, got: {report}"
1659        );
1660        assert!(
1661            !executed.load(Ordering::SeqCst),
1662            "node must not execute successfully without the extension"
1663        );
1664
1665        // A context carrying the extension executes the node successfully.
1666        let options = LanceExecutionOptions::default();
1667        let session_ctx = get_session_context(&options);
1668        let config = session_ctx
1669            .task_ctx()
1670            .session_config()
1671            .clone()
1672            .with_extension(Arc::new(RequiredExtension));
1673        let task_ctx = session_ctx.task_ctx();
1674        let task_ctx = Arc::new(TaskContext::new(
1675            task_ctx.task_id(),
1676            task_ctx.session_id(),
1677            config,
1678            task_ctx.scalar_functions().clone(),
1679            task_ctx.higher_order_functions().clone(),
1680            task_ctx.aggregate_functions().clone(),
1681            task_ctx.window_functions().clone(),
1682            task_ctx.runtime_env(),
1683        ));
1684        let report = analyze_plan_with_context(plan, options, Some(task_ctx))
1685            .await
1686            .expect("analyze should succeed when the extension is present");
1687        assert!(report.contains("NeedsExtensionExec"));
1688        // The node only reaches this flag when the supplied context is actually
1689        // forwarded to `execute`; dropping the forwarding fails this assertion.
1690        assert!(
1691            executed.load(Ordering::SeqCst),
1692            "supplied context must be forwarded so the node executes"
1693        );
1694    }
1695}