Skip to main content

delta_arrow_reader/
reader.rs

1//! Public Delta-to-Arrow reader and optional DataFusion adapter.
2
3pub(crate) mod backend;
4#[cfg(feature = "datafusion")]
5pub mod datafusion;
6pub(crate) mod deletion_vector;
7pub(crate) mod metrics;
8mod options;
9pub(crate) mod partition_target;
10pub(crate) mod planning;
11pub(crate) mod predicate;
12#[allow(dead_code)]
13pub(crate) mod scheduling;
14pub(crate) mod transform;
15
16#[doc(hidden)]
17pub use metrics::ParquetRangePlanningDiagnosticSnapshot;
18pub use metrics::{DeltaScanMetrics, DeltaScanMetricsSnapshot};
19#[doc(hidden)]
20pub use options::ParquetRangeReadPolicy;
21pub use options::{
22    DeltaScanExecutionOptions, DeltaSnapshotSelection, DeltaStorageOptions, ParquetReaderBackend,
23};
24pub use predicate::{DeltaComparison, DeltaPredicate, DeltaScalar};
25
26use std::{
27    fmt,
28    pin::Pin,
29    sync::Arc,
30    task::{Context, Poll},
31    time::Duration,
32};
33
34use arrow::{
35    datatypes::{DataType, SchemaRef},
36    record_batch::RecordBatch,
37};
38use futures_util::Stream;
39use snafu::ResultExt;
40
41use self::{
42    backend::direct_parquet::{
43        NetworkWarmupPlan, ParquetRangeReadEstimator, ParquetRangeReadState,
44    },
45    planning::{
46        DeltaScanPartitionTargetOptions, DeltaScanPlan, build_physical_row_predicate, plan_scan,
47    },
48    predicate::{column_data_type, evaluate_predicate, referenced_columns, validate_predicate},
49    scheduling::{
50        DeltaScanScheduler, FileAdmissionDecision, FileAdmissionPolicy, FileBatchStream,
51        FileExecutor, OrderedPartitionStream,
52    },
53};
54
55use crate::{
56    DeltaProtocol, DeltaReaderError,
57    delta::{
58        kernel::{
59            DeltaKernelEngineContext, DeltaKernelPredicate, kernel_pruning_predicate,
60            kernel_row_predicate,
61        },
62        protocol::validate_protocol,
63        snapshot::{
64            ArrowTableSnapshot, KernelTableSnapshot, load_delta_table_snapshot,
65            load_kernel_table_snapshot,
66        },
67    },
68    error::{DataFileReadSnafu, InvalidConfigurationSnafu, ScanPlanningSnafu, SnapshotLoadSnafu},
69};
70
71const TRACING_TARGET: &str = "delta_arrow_reader";
72const DELTA_LOG_SCAN_METADATA_SOURCE: &str = "delta_log";
73const QUERY_PLANNING_CACHE_SCAN_METADATA_SOURCE: &str = "query_planning_cache";
74
75/// Selects how much work table loading performs before returning.
76///
77/// The default prepares supported S3 tables for repeated queries. Explicit modes select
78/// how much work to do before the first query.
79#[non_exhaustive]
80#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
81pub enum WarmupMode {
82    /// Uses network warmup for supported S3 stores when partial-page reads are enabled.
83    ///
84    /// Network sampling has a five-second limit after metadata loading. Other stores,
85    /// the Delta Kernel backend, and explicit range policies perform no warmup. If sampling
86    /// starts but does not finish, the first later eligible scan retries it once during planning.
87    #[default]
88    Automatic,
89    /// Performs no warmup.
90    None,
91    /// Loads and retains the active-file metadata used during query planning.
92    QueryPlanning,
93    /// Retains query-planning metadata and samples remote data-file reads.
94    ///
95    /// The time limit applies to network sampling, after metadata loading. Sampling is
96    /// best effort, uses at most three files, and schedules at most 24.375 MiB of payload.
97    /// Only the Direct backend with an automatic range policy and a supported S3
98    /// transport performs network sampling. Other settings keep metadata warmup only. An
99    /// incomplete attempt can run once more, with the same limit, during an eligible scan.
100    Network {
101        /// Maximum time spent sampling network reads. Zero skips sampling.
102        max_duration: Duration,
103    },
104}
105
106impl WarmupMode {
107    fn for_table(
108        self,
109        context: &DeltaKernelEngineContext,
110        options: DeltaScanExecutionOptions,
111    ) -> Self {
112        if self != Self::Automatic {
113            return self;
114        }
115        if NetworkWarmupPlan::supports_scan(options) && context.supports_partial_reads() {
116            Self::Network {
117                max_duration: Duration::from_secs(5),
118            }
119        } else {
120            Self::None
121        }
122    }
123}
124
125/// Configures and loads one immutable Delta table snapshot.
126///
127/// The asynchronous path uses the caller's Tokio runtime. Scans return a
128/// pull-driven stream and do not materialize the whole table.
129///
130/// # Example
131///
132/// ```no_run
133/// use delta_arrow_reader::{DeltaComparison, DeltaPredicate, DeltaScalar, DeltaTableBuilder};
134///
135/// # async fn read_table() -> Result<(), Box<dyn std::error::Error>> {
136/// let table = DeltaTableBuilder::new("/tmp/example-delta-table")
137///     .load_table()
138///     .await?;
139/// let scan = table
140///     .scan()
141///     .with_projection(["id", "name"])
142///     .with_predicate(DeltaPredicate::Compare {
143///         column: "id".into(),
144///         op: DeltaComparison::GtEq,
145///         value: DeltaScalar::Int64(10),
146///     })
147///     .with_limit(100)
148///     .build()
149///     .await?;
150/// let mut batches = scan.into_stream();
151///
152/// while let Some(batch) = batches.next_batch().await? {
153///     println!("rows={}", batch.num_rows());
154/// }
155/// # Ok(())
156/// # }
157/// ```
158#[must_use = "table builder settings do nothing unless the builder is loaded"]
159pub struct DeltaTableBuilder {
160    table_location: String,
161    storage_options: DeltaStorageOptions,
162    snapshot_selection: DeltaSnapshotSelection,
163    execution_options: DeltaScanExecutionOptions,
164    warmup: WarmupMode,
165}
166
167impl DeltaTableBuilder {
168    /// Creates a builder for the latest snapshot with default execution settings.
169    pub fn new(table_location: impl Into<String>) -> Self {
170        Self {
171            table_location: table_location.into(),
172            storage_options: DeltaStorageOptions::new(),
173            snapshot_selection: DeltaSnapshotSelection::Latest,
174            execution_options: DeltaScanExecutionOptions::new(),
175            warmup: WarmupMode::Automatic,
176        }
177    }
178
179    /// Replaces the storage options forwarded during table loading.
180    pub fn with_storage_options(mut self, storage_options: DeltaStorageOptions) -> Self {
181        self.storage_options = storage_options;
182        self
183    }
184
185    /// Selects the Delta snapshot to load.
186    pub const fn with_snapshot_selection(
187        mut self,
188        snapshot_selection: DeltaSnapshotSelection,
189    ) -> Self {
190        self.snapshot_selection = snapshot_selection;
191        self
192    }
193
194    /// Replaces the default execution settings used by scans of this table.
195    pub const fn with_execution_options(
196        mut self,
197        execution_options: DeltaScanExecutionOptions,
198    ) -> Self {
199        self.execution_options = execution_options;
200        self
201    }
202
203    /// Selects work to finish during [`Self::load_table`] for reuse by later queries.
204    ///
205    /// Defaults to [`WarmupMode::Automatic`]. [`Self::load_snapshot`] does not warm up
206    /// a table and accepts only `Automatic` or [`WarmupMode::None`].
207    pub const fn with_warmup(mut self, warmup: WarmupMode) -> Self {
208        self.warmup = warmup;
209        self
210    }
211
212    /// Loads the table through the caller-owned Tokio runtime.
213    ///
214    /// The default [`WarmupMode::Automatic`] prepares query-planning metadata and samples
215    /// the network for supported S3 stores. Other stores remain lazy. Select
216    /// [`WarmupMode::None`] to skip this preparation, including all network probes.
217    /// A timed-out, failed, or incomplete network sample does not fail loading. The first
218    /// later filtered scan that can use partial-page reads retries sampling once while it builds.
219    ///
220    /// With [`WarmupMode::None`], this method loads the snapshot and converts its schema. Protocol
221    /// validation and scan metadata loading remain deferred until a scan is built. This allows an
222    /// application to inspect the version, schema, and protocol of a table that the reader cannot
223    /// scan.
224    ///
225    /// A warmup mode validates the protocol while loading because it builds reusable query-planning
226    /// state. Query-planning warmup retains active-file metadata.
227    ///
228    /// # Errors
229    ///
230    /// Returns an error if the table location, storage, snapshot, or schema cannot be loaded. A
231    /// warmup can also fail if the table protocol is unsupported or its reusable metadata cannot be
232    /// built.
233    pub async fn load_table(self) -> Result<DeltaTable, DeltaReaderError> {
234        let snapshot = load_delta_table_snapshot(
235            self.table_location,
236            self.storage_options,
237            self.snapshot_selection,
238        )
239        .await?;
240        let warmup = self
241            .warmup
242            .for_table(snapshot.engine_context(), self.execution_options);
243        let snapshot = if warmup == WarmupMode::None {
244            snapshot
245        } else {
246            validate_protocol(snapshot.protocol())?;
247            materialize_eager_scan_metadata(snapshot).await?
248        };
249        let table = DeltaTable::new(snapshot, self.execution_options);
250        if let WarmupMode::Network { max_duration } = warmup
251            && let Some(plan) = NetworkWarmupPlan::new(
252                table.snapshot.as_ref(),
253                self.execution_options,
254                max_duration,
255            )
256        {
257            match plan
258                .run("initialization", &table.range_read_estimator())
259                .await
260            {
261                Some(estimator) => table.range_read_state.set_estimator(estimator),
262                None => table.range_read_state.defer_network_warmup(plan).await,
263            }
264        }
265        Ok(table)
266    }
267
268    /// Loads a Delta Kernel snapshot without converting its logical Arrow schema.
269    ///
270    /// This method never performs warmup. The default [`WarmupMode::Automatic`] and
271    /// explicit [`WarmupMode::None`] are accepted; requesting metadata or network
272    /// warmup returns a configuration error.
273    ///
274    /// # Errors
275    ///
276    /// Returns an error if warmup was requested or if the table location, storage, or snapshot
277    /// cannot be loaded.
278    pub async fn load_snapshot(self) -> Result<DeltaTableSnapshot, DeltaReaderError> {
279        if !matches!(self.warmup, WarmupMode::Automatic | WarmupMode::None) {
280            return InvalidConfigurationSnafu {
281                reason: "snapshot_load_does_not_support_table_warmup",
282            }
283            .fail();
284        }
285        let snapshot = load_kernel_table_snapshot(
286            self.table_location,
287            self.storage_options,
288            self.snapshot_selection,
289        )
290        .await?;
291        Ok(DeltaTableSnapshot::new(snapshot, self.execution_options))
292    }
293}
294
295async fn materialize_eager_scan_metadata(
296    snapshot: ArrowTableSnapshot,
297) -> Result<ArrowTableSnapshot, DeltaReaderError> {
298    tokio::task::spawn_blocking(move || snapshot.materialize_eager_scan_metadata())
299        .await
300        .boxed()
301        .context(ScanPlanningSnafu {
302            reason: "query_planning_warmup_task_failed",
303        })?
304}
305
306impl fmt::Debug for DeltaTableBuilder {
307    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
308        formatter
309            .debug_struct("DeltaTableBuilder")
310            .field("table_location", &"<redacted>")
311            .field("storage_options", &"<redacted>")
312            .field("snapshot_selection", &self.snapshot_selection)
313            .field("execution_options", &self.execution_options)
314            .field("warmup", &self.warmup)
315            .finish()
316    }
317}
318
319/// Loaded Delta snapshot metadata awaiting logical Arrow schema conversion.
320pub struct DeltaTableSnapshot {
321    snapshot: KernelTableSnapshot,
322    execution_options: DeltaScanExecutionOptions,
323}
324
325impl DeltaTableSnapshot {
326    fn new(snapshot: KernelTableSnapshot, execution_options: DeltaScanExecutionOptions) -> Self {
327        Self {
328            snapshot,
329            execution_options,
330        }
331    }
332
333    /// Returns the loaded Delta snapshot version.
334    pub fn version(&self) -> u64 {
335        self.snapshot.version()
336    }
337
338    /// Returns the loaded Delta protocol metadata.
339    pub fn protocol(&self) -> &DeltaProtocol {
340        self.snapshot.protocol()
341    }
342
343    /// Returns the normalized table URL.
344    ///
345    /// This value may contain sensitive caller input. Do not log or expose it.
346    pub fn table_url(&self) -> &str {
347        self.snapshot.table_url()
348    }
349
350    /// Validates the loaded snapshot against the supported reader protocol.
351    pub fn validate_protocol(&self) -> Result<(), DeltaReaderError> {
352        validate_protocol(self.protocol())
353    }
354
355    /// Converts the logical Arrow schema and finishes constructing the table.
356    pub fn into_table(self) -> Result<DeltaTable, DeltaReaderError> {
357        Ok(DeltaTable::new(
358            self.snapshot.into_arrow_snapshot()?,
359            self.execution_options,
360        ))
361    }
362}
363
364impl fmt::Debug for DeltaTableSnapshot {
365    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
366        formatter
367            .debug_struct("DeltaTableSnapshot")
368            .field("version", &self.version())
369            .finish_non_exhaustive()
370    }
371}
372
373/// One immutable loaded Delta table snapshot.
374#[derive(Clone)]
375pub struct DeltaTable {
376    snapshot: Arc<ArrowTableSnapshot>,
377    execution_options: DeltaScanExecutionOptions,
378    range_read_state: Arc<ParquetRangeReadState>,
379}
380
381impl DeltaTable {
382    fn new(snapshot: ArrowTableSnapshot, execution_options: DeltaScanExecutionOptions) -> Self {
383        Self {
384            snapshot: Arc::new(snapshot),
385            execution_options,
386            range_read_state: Arc::default(),
387        }
388    }
389
390    pub(crate) fn range_read_estimator(&self) -> Arc<ParquetRangeReadEstimator> {
391        self.range_read_state.estimator()
392    }
393
394    pub(crate) async fn recover_network_warmup(&self, options: DeltaScanExecutionOptions) {
395        self.range_read_state.recover_network_warmup(options).await;
396    }
397
398    #[cfg(feature = "datafusion")]
399    pub(crate) fn network_warmup_pending(&self, options: DeltaScanExecutionOptions) -> bool {
400        self.range_read_state.network_warmup_pending(options)
401    }
402
403    /// Returns the loaded Delta snapshot version.
404    pub fn version(&self) -> u64 {
405        self.snapshot.version()
406    }
407
408    /// Returns a shared handle to the logical Arrow schema.
409    pub fn schema(&self) -> SchemaRef {
410        self.snapshot.schema()
411    }
412
413    /// Returns a predicate column's logical Arrow type from the loaded schema.
414    ///
415    /// The column must be an unqualified, top-level logical name. This lookup
416    /// uses the same column validation as scan predicates and performs no I/O.
417    ///
418    /// # Errors
419    ///
420    /// Returns an error for empty, dotted, missing, or ambiguous column names.
421    pub fn predicate_column_type(&self, column: &str) -> Result<DataType, DeltaReaderError> {
422        column_data_type(self.schema().as_ref(), column).cloned()
423    }
424
425    /// Returns the loaded Delta protocol metadata.
426    pub fn protocol(&self) -> &DeltaProtocol {
427        self.snapshot.protocol()
428    }
429
430    /// Returns the normalized table URL.
431    ///
432    /// This value may contain sensitive caller input. Do not log or expose it.
433    pub fn table_url(&self) -> &str {
434        self.snapshot.table_url()
435    }
436
437    #[allow(dead_code)]
438    pub(crate) fn partition_columns(&self) -> &[String] {
439        self.snapshot.partition_columns()
440    }
441
442    #[allow(dead_code)]
443    pub(crate) fn snapshot(&self) -> &ArrowTableSnapshot {
444        self.snapshot.as_ref()
445    }
446
447    /// Validates the loaded snapshot against the supported reader protocol.
448    pub fn validate_protocol(&self) -> Result<(), DeltaReaderError> {
449        validate_protocol(self.protocol())
450    }
451
452    /// Loads the latest version from this table and returns it as a new immutable table.
453    ///
454    /// The current table and any scans built from it remain fixed at their original version. A
455    /// query-planning metadata cache is refreshed for the new version and reused directly when the
456    /// version has not changed.
457    ///
458    /// # Errors
459    ///
460    /// Returns an error if the newer snapshot, schema, or retained query-planning metadata cannot
461    /// be loaded. No partially refreshed table is returned.
462    pub async fn refresh(&self) -> Result<Self, DeltaReaderError> {
463        let snapshot = Arc::clone(&self.snapshot);
464        let range_read_estimator = self.range_read_estimator();
465        let snapshot = tokio::task::spawn_blocking(move || {
466            // Refresh may keep reading metadata after its async caller is dropped.
467            let _active_read = range_read_estimator.start_read();
468            snapshot.refresh()
469        })
470        .await
471        .boxed()
472        .context(SnapshotLoadSnafu {
473            reason: "snapshot_refresh_task_failed",
474        })
475        .and_then(|result| result)?;
476        Ok(Self {
477            snapshot: Arc::new(snapshot),
478            execution_options: self.execution_options,
479            range_read_state: Arc::clone(&self.range_read_state),
480        })
481    }
482
483    /// Starts configuring a new single-use scan.
484    pub fn scan(&self) -> DeltaScanBuilder<'_> {
485        DeltaScanBuilder {
486            table: self,
487            projection: None,
488            predicate: None,
489            limit: None,
490            target_partitions: None,
491            execution_options: self.execution_options,
492        }
493    }
494}
495
496impl fmt::Debug for DeltaTable {
497    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
498        formatter
499            .debug_struct("DeltaTable")
500            .field("version", &self.version())
501            .finish_non_exhaustive()
502    }
503}
504
505/// Configures one single-use streaming Delta scan.
506#[must_use = "scan builder settings do nothing unless the scan is built"]
507pub struct DeltaScanBuilder<'table> {
508    table: &'table DeltaTable,
509    projection: Option<Vec<String>>,
510    predicate: Option<DeltaPredicate>,
511    limit: Option<usize>,
512    target_partitions: Option<usize>,
513    execution_options: DeltaScanExecutionOptions,
514}
515
516impl<'table> DeltaScanBuilder<'table> {
517    /// Selects visible logical columns in caller order.
518    pub fn with_projection(
519        mut self,
520        logical_columns: impl IntoIterator<Item = impl Into<String>>,
521    ) -> Self {
522        self.projection = Some(logical_columns.into_iter().map(Into::into).collect());
523        self
524    }
525
526    /// Replaces the exact logical row predicate.
527    pub fn with_predicate(mut self, predicate: DeltaPredicate) -> Self {
528        self.predicate = Some(predicate);
529        self
530    }
531
532    /// Sets the maximum number of output rows.
533    pub const fn with_limit(mut self, limit: usize) -> Self {
534        self.limit = Some(limit);
535        self
536    }
537
538    /// Overrides the number of planned scan partitions.
539    pub fn with_target_partitions(
540        mut self,
541        target_partitions: usize,
542    ) -> Result<Self, DeltaReaderError> {
543        if target_partitions == 0 {
544            return InvalidConfigurationSnafu {
545                reason: "scan_partition_target_must_be_positive",
546            }
547            .fail();
548        }
549        self.target_partitions = Some(target_partitions);
550        Ok(self)
551    }
552
553    /// Replaces the execution settings for this scan.
554    pub const fn with_execution_options(
555        mut self,
556        execution_options: DeltaScanExecutionOptions,
557    ) -> Self {
558        self.execution_options = execution_options;
559        self
560    }
561
562    /// Builds one immutable single-use scan plan.
563    ///
564    /// Planning loads Delta metadata. If network warmup did not finish during table loading,
565    /// an eligible filtered scan also makes one bounded recovery attempt. See [`WarmupMode`].
566    /// Reads that produce query results begin when the scan's stream is polled.
567    pub async fn build(self) -> Result<DeltaScan, DeltaReaderError> {
568        self.table.validate_protocol()?;
569        if let Some(predicate) = self.predicate.as_ref() {
570            validate_predicate(predicate, self.table.schema().as_ref())?;
571        }
572
573        let snapshot_version = self.table.version();
574        let backend = self.execution_options.parquet_backend();
575        let scan_metadata_source = if self.table.snapshot.eager_scan_metadata().is_some() {
576            QUERY_PLANNING_CACHE_SCAN_METADATA_SOURCE
577        } else {
578            DELTA_LOG_SCAN_METADATA_SOURCE
579        };
580        trace_planning_started(snapshot_version, backend, scan_metadata_source);
581        let snapshot = Arc::clone(&self.table.snapshot);
582        let projection = self.projection;
583        let predicate = self.predicate;
584        let hidden_columns = predicate
585            .as_ref()
586            .map(referenced_columns)
587            .unwrap_or_default();
588        let row_predicate = predicate
589            .as_ref()
590            .filter(|_| backend == ParquetReaderBackend::Direct)
591            .and_then(|predicate| kernel_row_predicate(predicate, snapshot.partition_columns()));
592        let kernel_predicate = predicate.as_ref().and_then(kernel_pruning_predicate);
593        let include_stats = kernel_predicate.is_some();
594        let execution_options = self.execution_options;
595        let target_partitions = self.target_partitions;
596        let result = tokio::task::spawn_blocking(move || {
597            // Reuse the already-mapped predicate for ordinary data-only filters.
598            let same_predicate = row_predicate.is_some() && row_predicate == kernel_predicate;
599            let plan = plan_scan(
600                snapshot.as_ref(),
601                projection.as_deref(),
602                &hidden_columns,
603                kernel_predicate,
604                include_stats,
605                execution_options,
606                DeltaScanPartitionTargetOptions {
607                    explicit_target_partitions: target_partitions,
608                    datafusion_target_partitions: None,
609                },
610            )?;
611            let physical_row_predicate = if plan.partitions.is_empty() {
612                None
613            } else if same_predicate {
614                plan.physical_predicate.clone()
615            } else {
616                build_physical_row_predicate(
617                    snapshot.as_ref(),
618                    projection.as_deref(),
619                    &hidden_columns,
620                    row_predicate,
621                )?
622            };
623            Ok((plan, physical_row_predicate))
624        })
625        .await
626        .boxed()
627        .context(ScanPlanningSnafu {
628            reason: "scan_planning_task_failed",
629        })
630        .and_then(|result| result);
631
632        match result {
633            Ok((plan, physical_row_predicate)) => {
634                if self.limit != Some(0)
635                    && physical_row_predicate.is_some()
636                    && !plan.partitions.is_empty()
637                {
638                    self.table
639                        .recover_network_warmup(self.execution_options)
640                        .await;
641                }
642                trace_planning_completed(
643                    snapshot_version,
644                    backend,
645                    plan.partitions.len(),
646                    scan_metadata_source,
647                );
648                Ok(DeltaScan {
649                    plan: Arc::new(plan),
650                    predicate,
651                    limit: self.limit,
652                    physical_row_predicate,
653                    range_read_estimator: self.table.range_read_estimator(),
654                })
655            }
656            Err(error) => {
657                trace_planning_failed(snapshot_version, backend, scan_metadata_source, &error);
658                Err(error)
659            }
660        }
661    }
662}
663
664/// One immutable, single-use streaming Delta scan plan.
665///
666/// A scan cannot be cloned or converted into a stream twice.
667///
668/// ```compile_fail
669/// use delta_arrow_reader::DeltaScan;
670///
671/// fn stream_twice(scan: DeltaScan) {
672///     let _ = scan.into_stream();
673///     let _ = scan.into_stream();
674/// }
675/// ```
676///
677/// ```compile_fail
678/// use delta_arrow_reader::DeltaScan;
679///
680/// fn clone_scan(scan: DeltaScan) {
681///     let _ = scan.clone();
682/// }
683/// ```
684#[must_use = "scans do nothing unless converted into a stream"]
685pub struct DeltaScan {
686    plan: Arc<DeltaScanPlan>,
687    predicate: Option<DeltaPredicate>,
688    limit: Option<usize>,
689    physical_row_predicate: Option<DeltaKernelPredicate>,
690    range_read_estimator: Arc<ParquetRangeReadEstimator>,
691}
692
693impl DeltaScan {
694    /// Returns a shared handle to the visible logical output schema.
695    pub fn schema(&self) -> SchemaRef {
696        Arc::clone(&self.plan.projected_schema)
697    }
698
699    /// Returns the number of planned execution partitions.
700    pub fn partition_count(&self) -> usize {
701        self.plan.partitions.len()
702    }
703
704    /// Converts the scan into a pull-driven Arrow batch stream.
705    ///
706    /// Reads for query results begin when the stream is polled. Network warmup may already
707    /// have read calibration ranges during table loading or scan building.
708    pub fn into_stream(self) -> DeltaBatchStream {
709        let metrics = self.plan.metrics.clone();
710        let schema = Arc::clone(&self.plan.projected_schema);
711        let partition_count = self.plan.partitions.len();
712        let snapshot_version = self.plan.snapshot_version;
713        let backend = self.plan.execution_options.parquet_backend();
714        let projection = (self.plan.logical_schema.as_ref() != schema.as_ref())
715            .then(|| (0..schema.fields().len()).collect::<Vec<_>>());
716        let partitions = if self.limit == Some(0) {
717            OrderedPartitionStream::default()
718        } else {
719            let scheduler = DeltaScanScheduler::new(Arc::clone(&self.plan));
720            let admission: FileAdmissionPolicy<_> = Arc::new(|_| Ok(FileAdmissionDecision::Admit));
721            let executor = match backend {
722                ParquetReaderBackend::Direct => {
723                    backend::direct_parquet::direct_parquet_file_executor(
724                        &self.plan,
725                        None,
726                        self.physical_row_predicate,
727                        self.range_read_estimator,
728                        None,
729                    )
730                }
731                ParquetReaderBackend::DeltaKernel => {
732                    delta_kernel_executor(&self.plan, self.range_read_estimator)
733                }
734            };
735            scheduler.partition_streams(admission, executor)
736        };
737
738        DeltaBatchStream {
739            schema,
740            metrics,
741            partitions,
742            predicate: self.predicate,
743            projection,
744            remaining: self.limit,
745            snapshot_version,
746            backend,
747            partition_count,
748            started: false,
749            done: false,
750        }
751    }
752}
753
754/// Pull-driven stream of finalized logical Arrow batches from one Delta scan.
755///
756/// The stream has no inherent whole-result collection method. Callers that
757/// intentionally materialize a result can import [`TryStreamExt`](crate::TryStreamExt).
758///
759/// ```compile_fail
760/// use delta_arrow_reader::DeltaBatchStream;
761///
762/// fn collect_without_opt_in(stream: DeltaBatchStream) {
763///     let _ = stream.collect();
764/// }
765/// ```
766#[must_use = "streams do nothing unless polled"]
767pub struct DeltaBatchStream {
768    schema: SchemaRef,
769    metrics: DeltaScanMetrics,
770    partitions: OrderedPartitionStream,
771    predicate: Option<DeltaPredicate>,
772    projection: Option<Vec<usize>>,
773    remaining: Option<usize>,
774    snapshot_version: u64,
775    backend: ParquetReaderBackend,
776    partition_count: usize,
777    started: bool,
778    done: bool,
779}
780
781impl DeltaBatchStream {
782    /// Reads the next batch, returning `None` when the stream is exhausted.
783    ///
784    /// # Errors
785    ///
786    /// Propagates errors from reading or processing a batch. The stream ends after an error.
787    pub async fn next_batch(&mut self) -> Result<Option<RecordBatch>, DeltaReaderError> {
788        futures_util::TryStreamExt::try_next(self).await
789    }
790
791    /// Returns a shared handle to the visible logical output schema.
792    pub fn schema(&self) -> SchemaRef {
793        Arc::clone(&self.schema)
794    }
795
796    /// Returns a lightweight shared handle to live scan metrics.
797    pub fn metrics(&self) -> DeltaScanMetrics {
798        self.metrics.clone()
799    }
800
801    fn start(&mut self) {
802        if self.started {
803            return;
804        }
805        self.started = true;
806        trace_execution_started(self.snapshot_version, self.backend, self.partition_count);
807    }
808
809    fn complete(&mut self) {
810        if self.done {
811            return;
812        }
813        self.partitions.clear();
814        self.done = true;
815        trace_execution_completed(self.snapshot_version, self.backend, self.partition_count);
816    }
817
818    fn fail(&mut self, error: &DeltaReaderError) {
819        self.partitions.clear();
820        self.done = true;
821        trace_execution_failed(
822            self.snapshot_version,
823            self.backend,
824            self.partition_count,
825            error,
826        );
827    }
828
829    fn finalize_batch(&self, mut batch: RecordBatch) -> Result<RecordBatch, DeltaReaderError> {
830        if let Some(predicate) = self.predicate.as_ref() {
831            batch = evaluate_predicate(&batch, predicate)?;
832        }
833        if let Some(projection) = self.projection.as_ref() {
834            batch = batch
835                .project(projection)
836                .boxed()
837                .context(DataFileReadSnafu {
838                    reason: "direct_projection_failed",
839                })?;
840        }
841        Ok(batch)
842    }
843}
844
845impl Stream for DeltaBatchStream {
846    type Item = Result<RecordBatch, DeltaReaderError>;
847
848    fn poll_next(self: Pin<&mut Self>, context: &mut Context<'_>) -> Poll<Option<Self::Item>> {
849        let this = self.get_mut();
850        if this.done {
851            return Poll::Ready(None);
852        }
853        this.start();
854
855        match Pin::new(&mut this.partitions).poll_next(context) {
856            Poll::Ready(Some(Ok(batch))) => {
857                let mut batch = match this.finalize_batch(batch) {
858                    Ok(batch) => batch,
859                    Err(error) => {
860                        this.fail(&error);
861                        return Poll::Ready(Some(Err(error)));
862                    }
863                };
864                if let Some(remaining) = this.remaining.as_mut() {
865                    if batch.num_rows() >= *remaining {
866                        batch = batch.slice(0, *remaining);
867                        *remaining = 0;
868                        this.complete();
869                    } else {
870                        *remaining -= batch.num_rows();
871                    }
872                }
873                Poll::Ready(Some(Ok(batch)))
874            }
875            Poll::Ready(Some(Err(error))) => {
876                this.fail(&error);
877                Poll::Ready(Some(Err(error)))
878            }
879            Poll::Ready(None) => {
880                this.complete();
881                Poll::Ready(None)
882            }
883            Poll::Pending => Poll::Pending,
884        }
885    }
886}
887
888impl Drop for DeltaBatchStream {
889    fn drop(&mut self) {
890        if self.done {
891            return;
892        }
893        self.partitions.clear();
894        self.done = true;
895        trace_execution_dropped(self.snapshot_version, self.backend, self.partition_count);
896    }
897}
898
899pub(crate) fn delta_kernel_executor(
900    plan: &Arc<DeltaScanPlan>,
901    range_read_estimator: Arc<ParquetRangeReadEstimator>,
902) -> FileExecutor<planning::DeltaScanFileTask, FileBatchStream> {
903    backend::kernel_reader::delta_kernel_file_executor(plan, range_read_estimator)
904}
905
906fn trace_planning_started(
907    snapshot_version: u64,
908    backend: ParquetReaderBackend,
909    scan_metadata_source: &'static str,
910) {
911    tracing::debug!(
912        target: TRACING_TARGET,
913        event = "scan_planning.started",
914        snapshot_version,
915        backend = ?backend,
916        partition_count = tracing::field::Empty,
917        scan_metadata_source,
918        outcome = "started"
919    );
920}
921
922fn trace_planning_completed(
923    snapshot_version: u64,
924    backend: ParquetReaderBackend,
925    partition_count: usize,
926    scan_metadata_source: &'static str,
927) {
928    tracing::debug!(
929        target: TRACING_TARGET,
930        event = "scan_planning.completed",
931        snapshot_version,
932        backend = ?backend,
933        partition_count,
934        scan_metadata_source,
935        outcome = "completed"
936    );
937}
938
939fn trace_planning_failed(
940    snapshot_version: u64,
941    backend: ParquetReaderBackend,
942    scan_metadata_source: &'static str,
943    error: &DeltaReaderError,
944) {
945    tracing::debug!(
946        target: TRACING_TARGET,
947        event = "scan_planning.failed",
948        snapshot_version,
949        backend = ?backend,
950        partition_count = tracing::field::Empty,
951        scan_metadata_source,
952        outcome = "failed",
953        error_code = error.code(),
954        error_phase = error.phase().as_str()
955    );
956}
957
958fn trace_execution_started(
959    snapshot_version: u64,
960    backend: ParquetReaderBackend,
961    partition_count: usize,
962) {
963    tracing::debug!(
964        target: TRACING_TARGET,
965        event = "scan_execution.started",
966        snapshot_version,
967        backend = ?backend,
968        partition_count,
969        outcome = "started"
970    );
971}
972
973fn trace_execution_completed(
974    snapshot_version: u64,
975    backend: ParquetReaderBackend,
976    partition_count: usize,
977) {
978    tracing::debug!(
979        target: TRACING_TARGET,
980        event = "scan_execution.completed",
981        snapshot_version,
982        backend = ?backend,
983        partition_count,
984        outcome = "completed"
985    );
986}
987
988fn trace_execution_failed(
989    snapshot_version: u64,
990    backend: ParquetReaderBackend,
991    partition_count: usize,
992    error: &DeltaReaderError,
993) {
994    tracing::debug!(
995        target: TRACING_TARGET,
996        event = "scan_execution.failed",
997        snapshot_version,
998        backend = ?backend,
999        partition_count,
1000        outcome = "failed",
1001        error_code = error.code(),
1002        error_phase = error.phase().as_str()
1003    );
1004}
1005
1006fn trace_execution_dropped(
1007    snapshot_version: u64,
1008    backend: ParquetReaderBackend,
1009    partition_count: usize,
1010) {
1011    tracing::debug!(
1012        target: TRACING_TARGET,
1013        event = "scan_execution.dropped",
1014        snapshot_version,
1015        backend = ?backend,
1016        partition_count,
1017        outcome = "dropped"
1018    );
1019}
1020
1021#[cfg(test)]
1022mod tests {
1023    use std::{
1024        collections::{BTreeMap, VecDeque},
1025        fmt, fs,
1026        future::pending,
1027        path::{Path, PathBuf},
1028        sync::{Arc, Mutex, Once},
1029        time::{Duration, SystemTime, UNIX_EPOCH},
1030    };
1031
1032    use arrow::{
1033        array::Int32Array,
1034        datatypes::{DataType, Field, Schema, SchemaRef},
1035        record_batch::RecordBatch,
1036    };
1037    use futures_util::{FutureExt, StreamExt, stream};
1038    use tokio::{sync::Notify, time::timeout};
1039    use tracing::{
1040        Event, Level, Metadata, Subscriber,
1041        field::{Field as TracingField, Visit},
1042        span::{Attributes, Id, Record},
1043        subscriber::{Interest, with_default},
1044    };
1045
1046    use super::{
1047        DeltaBatchStream, DeltaTable, trace_execution_completed, trace_execution_dropped,
1048        trace_execution_failed, trace_execution_started, trace_planning_completed,
1049        trace_planning_failed, trace_planning_started,
1050    };
1051    use crate::{
1052        DeltaScanExecutionOptions, DeltaScanMetrics, DeltaSnapshotSelection, DeltaStorageOptions,
1053        ParquetReaderBackend,
1054        delta::snapshot::load_delta_table_snapshot_blocking,
1055        error::InvalidConfigurationSnafu,
1056        reader::{
1057            metrics::DeltaScanMetricsConfig,
1058            scheduling::{
1059                FileAdmissionDecision, FileAdmissionPolicy, FileBatchStream, FileExecutor,
1060                FileReadPermit, OrderedPartitionStream, PartitionStream, ScanCancellation,
1061                ScanReadLimiter,
1062            },
1063        },
1064    };
1065
1066    static TRACING_TEST_LOCK: Mutex<()> = Mutex::new(());
1067    static TRACING_TEST_GLOBAL_SUBSCRIBER: Once = Once::new();
1068
1069    #[tokio::test]
1070    async fn automatic_warmup_respects_storage_and_explicit_options()
1071    -> Result<(), Box<dyn std::error::Error>> {
1072        use super::{
1073            DeltaKernelEngineContext, DeltaStorageOptions, DeltaTableBuilder,
1074            ParquetRangeReadPolicy, WarmupMode,
1075        };
1076
1077        let options = DeltaScanExecutionOptions::default();
1078        let automatic = DeltaTableBuilder::new("s3://bucket/table").warmup;
1079        assert_eq!(automatic, WarmupMode::default());
1080        let network = WarmupMode::Network {
1081            max_duration: Duration::from_secs(5),
1082        };
1083        for (location, extra, supported) in [
1084            ("s3://bucket/table", None, true),
1085            ("s3a://bucket/table", None, true),
1086            ("s3://bucket/table", Some(("timeout", "17s")), false),
1087            ("https://example.com/table/", None, false),
1088        ] {
1089            let mut storage = DeltaStorageOptions::from([
1090                ("aws_region".into(), "us-west-2".into()),
1091                ("aws_skip_signature".into(), "true".into()),
1092            ]);
1093            if let Some((key, value)) = extra {
1094                storage.insert(key.into(), value.into());
1095            }
1096            let context = DeltaKernelEngineContext::try_new(url::Url::parse(location)?, &storage)?;
1097            assert_eq!(
1098                automatic.for_table(&context, options),
1099                if supported { network } else { WarmupMode::None },
1100                "{location}, {extra:?}",
1101            );
1102            for disabled in [
1103                options.with_experimental_intra_page_reads(false),
1104                options.with_parquet_backend(ParquetReaderBackend::DeltaKernel),
1105                options.with_parquet_range_read_policy(ParquetRangeReadPolicy::ExactRanges),
1106            ] {
1107                assert_eq!(automatic.for_table(&context, disabled), WarmupMode::None);
1108            }
1109            for explicit in [WarmupMode::None, WarmupMode::QueryPlanning, network] {
1110                assert_eq!(explicit.for_table(&context, options), explicit);
1111                assert_eq!(
1112                    explicit.for_table(&context, options.with_experimental_intra_page_reads(false)),
1113                    explicit
1114                );
1115            }
1116        }
1117        Ok(())
1118    }
1119
1120    #[derive(Clone, Default)]
1121    struct EventFields(Arc<Mutex<Vec<BTreeMap<String, String>>>>);
1122
1123    impl Subscriber for EventFields {
1124        fn register_callsite(&self, metadata: &'static Metadata<'static>) -> Interest {
1125            if metadata.target() == "delta_arrow_reader" && *metadata.level() == Level::DEBUG {
1126                Interest::always()
1127            } else {
1128                Interest::sometimes()
1129            }
1130        }
1131
1132        fn enabled(&self, metadata: &Metadata<'_>) -> bool {
1133            metadata.target() == "delta_arrow_reader" && *metadata.level() == Level::DEBUG
1134        }
1135
1136        fn new_span(&self, _attributes: &Attributes<'_>) -> Id {
1137            Id::from_u64(1)
1138        }
1139
1140        fn record(&self, _span: &Id, _values: &Record<'_>) {}
1141
1142        fn record_follows_from(&self, _span: &Id, _follows: &Id) {}
1143
1144        fn event(&self, event: &Event<'_>) {
1145            let metadata = event.metadata();
1146            assert_eq!(metadata.target(), "delta_arrow_reader");
1147            let mut fields = metadata
1148                .fields()
1149                .iter()
1150                .map(|field| (field.name().to_owned(), "<empty>".to_owned()))
1151                .collect();
1152            event.record(&mut FieldVisitor(&mut fields));
1153            self.0.lock().expect("event lock").push(fields);
1154        }
1155
1156        fn enter(&self, _span: &Id) {}
1157
1158        fn exit(&self, _span: &Id) {}
1159    }
1160
1161    struct FieldVisitor<'fields>(&'fields mut BTreeMap<String, String>);
1162
1163    impl Visit for FieldVisitor<'_> {
1164        fn record_debug(&mut self, field: &TracingField, value: &dyn fmt::Debug) {
1165            self.0.insert(field.name().to_owned(), format!("{value:?}"));
1166        }
1167
1168        fn record_str(&mut self, field: &TracingField, value: &str) {
1169            self.0.insert(field.name().to_owned(), value.to_owned());
1170        }
1171
1172        fn record_u64(&mut self, field: &TracingField, value: u64) {
1173            self.0.insert(field.name().to_owned(), value.to_string());
1174        }
1175
1176        fn record_i64(&mut self, field: &TracingField, value: i64) {
1177            self.0.insert(field.name().to_owned(), value.to_string());
1178        }
1179
1180        fn record_u128(&mut self, field: &TracingField, value: u128) {
1181            self.0.insert(field.name().to_owned(), value.to_string());
1182        }
1183    }
1184
1185    struct DeltaLogTable(PathBuf);
1186
1187    impl DeltaLogTable {
1188        fn new(name: &str) -> Result<Self, Box<dyn std::error::Error>> {
1189            let nanos = SystemTime::now().duration_since(UNIX_EPOCH)?.as_nanos();
1190            let path = Path::new("target")
1191                .join("delta-arrow-reader-tracing-tests")
1192                .join(format!("{}-{name}-{nanos}", std::process::id()));
1193            fs::create_dir_all(path.join("_delta_log"))?;
1194            fs::write(
1195                path.join("_delta_log/00000000000000000000.json"),
1196                r#"{"protocol":{"minReaderVersion":1,"minWriterVersion":2}}
1197{"metaData":{"id":"tracing-test","format":{"provider":"parquet","options":{}},"schemaString":"{\"type\":\"struct\",\"fields\":[{\"name\":\"id\",\"type\":\"integer\",\"nullable\":true,\"metadata\":{}}]}","partitionColumns":[],"configuration":{},"createdTime":1587968585495}}
1198{"add":{"path":"secret-planning-object.parquet","partitionValues":{},"size":10,"modificationTime":1587968586000,"dataChange":true}}
1199"#,
1200            )?;
1201            Ok(Self(path))
1202        }
1203    }
1204
1205    impl Drop for DeltaLogTable {
1206        fn drop(&mut self) {
1207            let _ = fs::remove_dir_all(&self.0);
1208        }
1209    }
1210
1211    fn capture_events<T>(run: impl FnOnce() -> T) -> (T, Vec<BTreeMap<String, String>>) {
1212        let _lock = TRACING_TEST_LOCK
1213            .lock()
1214            .unwrap_or_else(std::sync::PoisonError::into_inner);
1215        let events = Arc::new(Mutex::new(Vec::new()));
1216        let subscriber = EventFields(Arc::clone(&events));
1217        TRACING_TEST_GLOBAL_SUBSCRIBER.call_once(|| {
1218            let _ = tracing::subscriber::set_global_default(EventFields::default());
1219        });
1220        let result = with_default(subscriber, || {
1221            tracing::callsite::rebuild_interest_cache();
1222            run()
1223        });
1224        tracing::callsite::rebuild_interest_cache();
1225        let captured = events
1226            .lock()
1227            .map(|events| events.clone())
1228            .unwrap_or_default();
1229        (result, captured)
1230    }
1231
1232    struct ControlledMerge {
1233        stream: DeltaBatchStream,
1234        limiter: Arc<ScanReadLimiter>,
1235        cancellation: ScanCancellation,
1236        metrics: DeltaScanMetrics,
1237        first_partition_gate: Arc<Notify>,
1238    }
1239
1240    fn schema() -> SchemaRef {
1241        Arc::new(Schema::new(vec![Field::new("id", DataType::Int32, false)]))
1242    }
1243
1244    fn batch(id: i32) -> RecordBatch {
1245        RecordBatch::try_new(schema(), vec![Arc::new(Int32Array::from(vec![id]))])
1246            .expect("valid test batch")
1247    }
1248
1249    fn batch_id(batch: &RecordBatch) -> i32 {
1250        batch
1251            .column(0)
1252            .as_any()
1253            .downcast_ref::<Int32Array>()
1254            .expect("Int32 id")
1255            .value(0)
1256    }
1257
1258    fn execution_options() -> Result<DeltaScanExecutionOptions, crate::DeltaReaderError> {
1259        DeltaScanExecutionOptions::new()
1260            .with_prefetch_files_per_partition(0)
1261            .with_max_concurrent_file_reads_per_partition(1)?
1262            .with_max_concurrent_file_reads_per_scan(Some(2))?
1263            .with_output_buffer_batches_per_partition(1)
1264    }
1265
1266    fn metrics() -> DeltaScanMetrics {
1267        DeltaScanMetrics::new(DeltaScanMetricsConfig {
1268            snapshot_version: 7,
1269            parquet_backend: ParquetReaderBackend::Direct,
1270            scan_partitions_planned: 2,
1271            files_planned: 2,
1272            add_actions_excluded_during_planning: Some(0),
1273            estimated_input_rows: Some(4),
1274            estimated_input_bytes: Some(4),
1275        })
1276    }
1277
1278    fn file_stream(permit: FileReadPermit, batches: Vec<RecordBatch>) -> FileBatchStream {
1279        Box::pin(stream::unfold(
1280            (VecDeque::from(batches), permit),
1281            |(mut batches, permit)| async move {
1282                batches
1283                    .pop_front()
1284                    .map(|batch| (Ok(batch), (batches, permit)))
1285            },
1286        ))
1287    }
1288
1289    fn gated_file_stream(
1290        permit: FileReadPermit,
1291        batches: Vec<RecordBatch>,
1292        gate: Arc<Notify>,
1293    ) -> FileBatchStream {
1294        Box::pin(stream::unfold(
1295            (false, VecDeque::from(batches), permit, gate),
1296            |(wait, mut batches, permit, gate)| async move {
1297                let batch = batches.pop_front()?;
1298                if wait {
1299                    gate.notified().await;
1300                }
1301                Some((Ok(batch), (true, batches, permit, gate)))
1302            },
1303        ))
1304    }
1305
1306    fn direct_stream(
1307        partitions: VecDeque<PartitionStream>,
1308        metrics: DeltaScanMetrics,
1309        scan_capacity: usize,
1310    ) -> DeltaBatchStream {
1311        DeltaBatchStream {
1312            schema: schema(),
1313            metrics,
1314            partitions: OrderedPartitionStream::new(partitions, scan_capacity),
1315            predicate: None,
1316            projection: None,
1317            remaining: None,
1318            snapshot_version: 7,
1319            backend: ParquetReaderBackend::Direct,
1320            partition_count: 2,
1321            started: false,
1322            done: false,
1323        }
1324    }
1325
1326    fn controlled_merge() -> Result<ControlledMerge, Box<dyn std::error::Error>> {
1327        let options = execution_options()?;
1328        let limiter = ScanReadLimiter::new(options, 2, 2);
1329        let cancellation = ScanCancellation::new();
1330        let metrics = metrics();
1331        let first_partition_gate = Arc::new(Notify::new());
1332        let executor: FileExecutor<i32, FileBatchStream> = {
1333            let gate = Arc::clone(&first_partition_gate);
1334            Arc::new(move |task, permit, _| {
1335                let gate = Arc::clone(&gate);
1336                async move {
1337                    let batches = vec![batch(task), batch(task * 2)];
1338                    Ok(if task == 1 {
1339                        gated_file_stream(permit, batches, gate)
1340                    } else {
1341                        file_stream(permit, batches)
1342                    })
1343                }
1344                .boxed()
1345            })
1346        };
1347        let admission: FileAdmissionPolicy<i32> =
1348            Arc::new(|_: &i32| Ok(FileAdmissionDecision::Admit));
1349        let first = PartitionStream::new(
1350            vec![1],
1351            limiter.partition(0)?,
1352            options,
1353            admission.clone(),
1354            Arc::clone(&executor),
1355            metrics.clone(),
1356            cancellation.clone(),
1357        );
1358        let second = PartitionStream::new(
1359            vec![10],
1360            limiter.partition(1)?,
1361            options,
1362            admission,
1363            executor,
1364            metrics.clone(),
1365            cancellation.clone(),
1366        );
1367
1368        Ok(ControlledMerge {
1369            stream: direct_stream(VecDeque::from([first, second]), metrics.clone(), 2),
1370            limiter,
1371            cancellation,
1372            metrics,
1373            first_partition_gate,
1374        })
1375    }
1376
1377    async fn wait_for_batches(metrics: &DeltaScanMetrics, expected: u64) {
1378        timeout(Duration::from_secs(5), async {
1379            while metrics.snapshot().scheduler_batches_emitted < expected {
1380                tokio::task::yield_now().await;
1381            }
1382        })
1383        .await
1384        .expect("batch production reached expected bound");
1385    }
1386
1387    #[test]
1388    fn lifecycle_tracing_has_only_bounded_fields() {
1389        let error = InvalidConfigurationSnafu { reason: "test" }.build();
1390
1391        let (_, events) = capture_events(|| {
1392            trace_planning_started(7, ParquetReaderBackend::Direct, "query_planning_cache");
1393            trace_planning_completed(7, ParquetReaderBackend::Direct, 2, "query_planning_cache");
1394            trace_planning_failed(
1395                7,
1396                ParquetReaderBackend::Direct,
1397                "query_planning_cache",
1398                &error,
1399            );
1400            trace_execution_started(7, ParquetReaderBackend::Direct, 2);
1401            trace_execution_completed(7, ParquetReaderBackend::Direct, 2);
1402            trace_execution_failed(7, ParquetReaderBackend::Direct, 2, &error);
1403            trace_execution_dropped(7, ParquetReaderBackend::Direct, 2);
1404        });
1405
1406        assert_eq!(events.len(), 7);
1407        let allowed = [
1408            "backend",
1409            "error_phase",
1410            "error_code",
1411            "event",
1412            "outcome",
1413            "partition_count",
1414            "scan_metadata_source",
1415            "snapshot_version",
1416        ];
1417        for fields in events.iter() {
1418            assert!(fields.keys().all(|field| allowed.contains(&field.as_str())));
1419            assert!(fields.contains_key("event"));
1420            assert!(fields.contains_key("snapshot_version"));
1421            assert!(fields.contains_key("backend"));
1422            assert!(fields.contains_key("partition_count"));
1423            assert!(fields.contains_key("outcome"));
1424            if fields
1425                .get("event")
1426                .is_some_and(|event| event.starts_with("scan_planning."))
1427            {
1428                assert_eq!(
1429                    fields.get("scan_metadata_source").map(String::as_str),
1430                    Some("query_planning_cache")
1431                );
1432            }
1433        }
1434    }
1435
1436    #[test]
1437    fn planning_tracing_reports_the_table_metadata_source_on_success_and_failure()
1438    -> Result<(), Box<dyn std::error::Error>> {
1439        const OBJECT_KEY: &str = "secret-planning-object.parquet";
1440        const STORAGE_VALUE: &str = "secret-planning-storage-value";
1441        let fixture = DeltaLogTable::new("planning-source")?;
1442        let mut storage_options = DeltaStorageOptions::new();
1443        storage_options.insert("secret-option".to_owned(), STORAGE_VALUE.to_owned());
1444        let snapshot = load_delta_table_snapshot_blocking(
1445            &fixture.0.to_string_lossy(),
1446            &storage_options,
1447            DeltaSnapshotSelection::Latest,
1448        )?;
1449        let eager_snapshot = snapshot.clone().materialize_eager_scan_metadata()?;
1450        let lazy = DeltaTable::new(snapshot, DeltaScanExecutionOptions::new());
1451        let eager = DeltaTable::new(eager_snapshot, DeltaScanExecutionOptions::new());
1452        let runtime = tokio::runtime::Builder::new_current_thread()
1453            .enable_all()
1454            .build()?;
1455
1456        let (eager_result, eager_events) =
1457            capture_events(|| runtime.block_on(eager.scan().build()));
1458        let _ = eager_result?;
1459        assert_eq!(eager_events.len(), 2);
1460        assert_eq!(
1461            eager_events[0].get("event").map(String::as_str),
1462            Some("scan_planning.started")
1463        );
1464        assert_eq!(
1465            eager_events[1].get("event").map(String::as_str),
1466            Some("scan_planning.completed")
1467        );
1468        assert!(eager_events.iter().all(|event| {
1469            event.get("scan_metadata_source").map(String::as_str) == Some("query_planning_cache")
1470        }));
1471
1472        let (failed_result, failed_events) =
1473            capture_events(|| runtime.block_on(eager.scan().with_projection(["missing"]).build()));
1474        assert!(failed_result.is_err());
1475        assert_eq!(failed_events.len(), 2);
1476        assert_eq!(
1477            failed_events[0].get("event").map(String::as_str),
1478            Some("scan_planning.started")
1479        );
1480        assert_eq!(
1481            failed_events[1].get("event").map(String::as_str),
1482            Some("scan_planning.failed")
1483        );
1484        assert!(failed_events.iter().all(|event| {
1485            event.get("scan_metadata_source").map(String::as_str) == Some("query_planning_cache")
1486        }));
1487
1488        let (lazy_result, lazy_events) = capture_events(|| runtime.block_on(lazy.scan().build()));
1489        let _ = lazy_result?;
1490        assert_eq!(lazy_events.len(), 2);
1491        assert!(lazy_events.iter().all(|event| {
1492            event.get("scan_metadata_source").map(String::as_str) == Some("delta_log")
1493        }));
1494
1495        let captured = format!("{eager_events:?}{failed_events:?}{lazy_events:?}");
1496        assert!(!captured.contains(&fixture.0.to_string_lossy().into_owned()));
1497        assert!(!captured.contains(OBJECT_KEY));
1498        assert!(!captured.contains(STORAGE_VALUE));
1499        Ok(())
1500    }
1501
1502    #[tokio::test]
1503    async fn merged_stream_stops_without_admitting_waiting_partitions()
1504    -> Result<(), Box<dyn std::error::Error>> {
1505        for stop in ["limit", "drop", "error"] {
1506            let options = execution_options()?.with_max_concurrent_file_reads_per_scan(Some(1))?;
1507            let limiter = ScanReadLimiter::new(options, 3, 3);
1508            let cancellation = ScanCancellation::new();
1509            let metrics = metrics();
1510            let executor: FileExecutor<i32, FileBatchStream> = Arc::new(move |task, permit, _| {
1511                async move {
1512                    Ok(if stop == "error" {
1513                        Box::pin(stream::once(async move {
1514                            let _permit = permit;
1515                            Err(InvalidConfigurationSnafu {
1516                                reason: "controlled_partition_failure",
1517                            }
1518                            .build())
1519                        })) as FileBatchStream
1520                    } else {
1521                        gated_file_stream(permit, vec![batch(task); 3], Arc::new(Notify::new()))
1522                    })
1523                }
1524                .boxed()
1525            });
1526            let partitions = (0..3)
1527                .map(|index| {
1528                    Ok(PartitionStream::new(
1529                        vec![index as i32; 2],
1530                        limiter.partition(index)?,
1531                        options,
1532                        Arc::new(|_| Ok(FileAdmissionDecision::Admit)),
1533                        Arc::clone(&executor),
1534                        metrics.clone(),
1535                        cancellation.clone(),
1536                    ))
1537                })
1538                .collect::<Result<VecDeque<_>, crate::DeltaReaderError>>()?;
1539            let mut stream = direct_stream(partitions, metrics.clone(), 1);
1540            if stop == "limit" {
1541                stream.remaining = Some(1);
1542            }
1543            let first = timeout(Duration::from_secs(5), stream.next())
1544                .await?
1545                .ok_or("missing result")?;
1546            if stop == "error" {
1547                assert_eq!(
1548                    first.expect_err("controlled failure").code(),
1549                    "invalid_configuration"
1550                );
1551            } else {
1552                assert_eq!(batch_id(&first?), 0);
1553            }
1554            if stop != "drop" {
1555                assert!(stream.next().await.is_none());
1556            }
1557            drop(stream);
1558            timeout(Duration::from_secs(5), async {
1559                while limiter.active_file_reads() != 0 {
1560                    tokio::task::yield_now().await;
1561                }
1562            })
1563            .await?;
1564            assert!(cancellation.is_cancelled());
1565            assert_eq!(metrics.snapshot().file_tasks_started, 1);
1566            assert_eq!(metrics.snapshot().scan_partitions_started, 1);
1567        }
1568        Ok(())
1569    }
1570
1571    #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
1572    async fn merged_stream_progresses_with_small_scan_read_limits()
1573    -> Result<(), Box<dyn std::error::Error>> {
1574        for backend in [
1575            ParquetReaderBackend::Direct,
1576            ParquetReaderBackend::DeltaKernel,
1577        ] {
1578            for cap in [1, 2, 4, 8] {
1579                for prefetch in [0, 2, usize::MAX] {
1580                    let options = DeltaScanExecutionOptions::new()
1581                        .with_parquet_backend(backend)
1582                        .with_max_concurrent_file_reads_per_scan(Some(cap))?
1583                        .with_prefetch_files_per_partition(prefetch);
1584                    let limiter = ScanReadLimiter::new(options, 5, 5);
1585                    let cancellation = ScanCancellation::new();
1586                    let metrics = metrics();
1587                    let executor: FileExecutor<i32, FileBatchStream> =
1588                        Arc::new(|task, permit, _| {
1589                            async move {
1590                                // Multiple batches fill later partitions' output queues while
1591                                // the front partition still needs permits for subsequent files.
1592                                Ok(file_stream(permit, vec![batch(task); 3]))
1593                            }
1594                            .boxed()
1595                        });
1596                    let partitions = (0..5)
1597                        .map(|partition| {
1598                            Ok(PartitionStream::new(
1599                                (partition * 4..partition * 4 + 4)
1600                                    .map(|id| id as i32)
1601                                    .collect(),
1602                                limiter.partition(partition)?,
1603                                options,
1604                                Arc::new(|_| Ok(FileAdmissionDecision::Admit)),
1605                                Arc::clone(&executor),
1606                                metrics.clone(),
1607                                cancellation.clone(),
1608                            ))
1609                        })
1610                        .collect::<Result<VecDeque<_>, crate::DeltaReaderError>>()?;
1611                    let mut stream = direct_stream(partitions, metrics.clone(), cap);
1612                    let ids = timeout(Duration::from_secs(2), async {
1613                        let mut ids = Vec::new();
1614                        while let Some(batch) = stream.next().await {
1615                            ids.push(batch_id(&batch?));
1616                            assert!(limiter.active_file_reads() <= cap);
1617                            tokio::task::yield_now().await;
1618                        }
1619                        Ok::<_, crate::DeltaReaderError>(ids)
1620                    })
1621                    .await
1622                    .unwrap_or_else(|_| {
1623                        panic!("stalled: {backend:?}, cap={cap}, prefetch={prefetch}")
1624                    })?;
1625                    assert_eq!(ids, (0..20).flat_map(|id| [id; 3]).collect::<Vec<_>>());
1626                    assert_eq!(limiter.active_file_reads(), 0);
1627                    assert_eq!(metrics.snapshot().file_tasks_completed, 20);
1628                    assert_eq!(metrics.snapshot().scan_partitions_completed, 5);
1629                }
1630            }
1631        }
1632        Ok(())
1633    }
1634
1635    #[tokio::test]
1636    async fn merged_stream_is_ordered_and_bounds_later_partition_queues()
1637    -> Result<(), Box<dyn std::error::Error>> {
1638        let ControlledMerge {
1639            mut stream,
1640            limiter,
1641            metrics,
1642            first_partition_gate,
1643            ..
1644        } = controlled_merge()?;
1645
1646        let first = stream.next().await.ok_or("first batch missing")??;
1647        assert_eq!(batch_id(&first), 1);
1648        wait_for_batches(&metrics, 2).await;
1649        for _ in 0..32 {
1650            tokio::task::yield_now().await;
1651        }
1652        assert_eq!(metrics.snapshot().scheduler_batches_emitted, 2);
1653        assert_eq!(limiter.active_file_reads(), 2);
1654
1655        // Cancelling a pending read must leave the next batch available.
1656        assert!(stream.next_batch().now_or_never().is_none());
1657        first_partition_gate.notify_one();
1658        let mut ids = vec![batch_id(
1659            &stream.next_batch().await?.ok_or("second batch missing")?,
1660        )];
1661        while let Some(batch) = stream.next_batch().await? {
1662            ids.push(batch_id(&batch));
1663        }
1664        assert_eq!(ids, [2, 10, 20]);
1665        assert_eq!(metrics.snapshot().scheduler_batches_emitted, 4);
1666        assert_eq!(metrics.snapshot().scan_partitions_completed, 2);
1667        assert_eq!(limiter.active_file_reads(), 0);
1668        Ok(())
1669    }
1670
1671    #[tokio::test]
1672    async fn merged_stream_drop_cancels_blocked_partitions_and_releases_permits()
1673    -> Result<(), Box<dyn std::error::Error>> {
1674        let ControlledMerge {
1675            mut stream,
1676            limiter,
1677            cancellation,
1678            metrics,
1679            ..
1680        } = controlled_merge()?;
1681
1682        let first = stream.next().await.ok_or("first batch missing")??;
1683        assert_eq!(batch_id(&first), 1);
1684        wait_for_batches(&metrics, 2).await;
1685        assert_eq!(limiter.active_file_reads(), 2);
1686        drop(stream);
1687
1688        assert!(cancellation.is_cancelled());
1689        timeout(Duration::from_secs(5), async {
1690            while limiter.active_file_reads() != 0 {
1691                tokio::task::yield_now().await;
1692            }
1693        })
1694        .await?;
1695        assert_eq!(metrics.snapshot().scheduler_batches_emitted, 2);
1696        assert_eq!(metrics.snapshot().scan_partitions_completed, 0);
1697        Ok(())
1698    }
1699
1700    #[tokio::test]
1701    async fn merged_stream_forwards_one_concurrent_error_and_releases_permits()
1702    -> Result<(), Box<dyn std::error::Error>> {
1703        let options = execution_options()?;
1704        let limiter = ScanReadLimiter::new(options, 3, 3);
1705        let cancellation = ScanCancellation::new();
1706        let metrics = metrics();
1707        let executor: FileExecutor<i32, FileBatchStream> = Arc::new(|task, permit, _| {
1708            async move {
1709                Ok(if task == 1 {
1710                    Box::pin(stream::once(async move {
1711                        let _permit = permit;
1712                        pending::<Result<RecordBatch, crate::DeltaReaderError>>().await
1713                    })) as FileBatchStream
1714                } else {
1715                    Box::pin(stream::once(async move {
1716                        let _permit = permit;
1717                        Err(InvalidConfigurationSnafu {
1718                            reason: "controlled_partition_failure",
1719                        }
1720                        .build())
1721                    })) as FileBatchStream
1722                })
1723            }
1724            .boxed()
1725        });
1726        let admission = Arc::new(|_: &i32| Ok(FileAdmissionDecision::Admit));
1727        let first = PartitionStream::new(
1728            vec![1],
1729            limiter.partition(0)?,
1730            options,
1731            admission.clone(),
1732            Arc::clone(&executor),
1733            metrics.clone(),
1734            cancellation.clone(),
1735        );
1736        let second = PartitionStream::new(
1737            vec![2],
1738            limiter.partition(1)?,
1739            options,
1740            admission.clone(),
1741            Arc::clone(&executor),
1742            metrics.clone(),
1743            cancellation.clone(),
1744        );
1745        let third = PartitionStream::new(
1746            vec![3],
1747            limiter.partition(2)?,
1748            options,
1749            admission,
1750            executor,
1751            metrics.clone(),
1752            cancellation.clone(),
1753        );
1754        let mut stream = direct_stream(VecDeque::from([first, second, third]), metrics.clone(), 2);
1755
1756        let error = timeout(Duration::from_secs(5), stream.next())
1757            .await?
1758            .ok_or("error item missing")?
1759            .expect_err("controlled partition must fail");
1760        assert_eq!(error.code(), "invalid_configuration");
1761        assert!(stream.next().await.is_none());
1762        assert!(cancellation.is_cancelled());
1763        assert_eq!(metrics.snapshot().file_tasks_started, 2);
1764        timeout(Duration::from_secs(5), async {
1765            while limiter.active_file_reads() != 0 {
1766                tokio::task::yield_now().await;
1767            }
1768        })
1769        .await?;
1770        Ok(())
1771    }
1772}