|
| 1 | +// SPDX-License-Identifier: Apache-2.0 |
| 2 | +// SPDX-FileCopyrightText: Copyright The Lance Authors |
| 3 | + |
| 4 | +//! Segment-scoped candidate generation for ordinary scans. The explicit fragment |
| 5 | +//! list is the read domain, including on fallback; a segment is only an accelerator. |
| 6 | +
|
| 7 | +use std::collections::HashSet; |
| 8 | +use std::sync::Arc; |
| 9 | +use std::time::Instant; |
| 10 | + |
| 11 | +use datafusion::physical_plan::metrics::ExecutionPlanMetricsSet; |
| 12 | +use lance::Dataset; |
| 13 | +use lance::dataset::scanner::{ |
| 14 | + ExecutionStatsCallback, ExecutionSummaryCounts, RowAddrMask, Scanner, |
| 15 | +}; |
| 16 | +use lance::index::{DatasetIndexExt, DatasetIndexInternalExt}; |
| 17 | +use lance::io::exec::utils::IndexMetrics; |
| 18 | +use lance_core::{Error, Result}; |
| 19 | +use lance_datafusion::planner::Planner; |
| 20 | +use lance_datafusion::utils::MetricsExt; |
| 21 | +use lance_index::IndexType; |
| 22 | +use lance_index::scalar::SearchResult; |
| 23 | +use lance_index::scalar::expression::{PlannerIndexExt, ScalarIndexExpr, ScalarIndexSearch}; |
| 24 | +use uuid::Uuid; |
| 25 | + |
| 26 | +pub(crate) struct PreparedScalarSegment { |
| 27 | + pub dataset: Arc<Dataset>, |
| 28 | + pub segment_uuid: Uuid, |
| 29 | + pub fragment_ids: Vec<u64>, |
| 30 | + pub callback: Option<ExecutionStatsCallback>, |
| 31 | +} |
| 32 | + |
| 33 | +fn invalid(message: impl Into<String>) -> Error { |
| 34 | + Error::invalid_input_source(message.into().into()) |
| 35 | +} |
| 36 | + |
| 37 | +// Only descend through AND: a leaf below OR or NOT need not contain all matches |
| 38 | +// of the full expression. The original expression is always reapplied by reader. |
| 39 | +fn driver<'a>(expr: &'a ScalarIndexExpr, index_name: &str) -> Option<&'a ScalarIndexSearch> { |
| 40 | + match expr { |
| 41 | + ScalarIndexExpr::Query(search) if search.index_name == index_name => Some(search), |
| 42 | + ScalarIndexExpr::And(lhs, rhs) => { |
| 43 | + driver(lhs, index_name).or_else(|| driver(rhs, index_name)) |
| 44 | + } |
| 45 | + _ => None, |
| 46 | + } |
| 47 | +} |
| 48 | + |
| 49 | +impl PreparedScalarSegment { |
| 50 | + pub async fn configure(self, mut reader: Scanner) -> Result<Scanner> { |
| 51 | + // Never let either candidate reads or fallback re-enter a global index search. |
| 52 | + reader.use_scalar_index(false); |
| 53 | + let mut stats = ExecutionSummaryCounts::default(); |
| 54 | + stats |
| 55 | + .all_counts |
| 56 | + .insert("scalar_segments_requested".into(), 1); |
| 57 | + let plan_metrics = ExecutionPlanMetricsSet::new(); |
| 58 | + let metrics = IndexMetrics::new(&plan_metrics, 0); |
| 59 | + let started = Instant::now(); |
| 60 | + let reason = self |
| 61 | + .configure_candidates(&mut reader, &metrics, &mut stats) |
| 62 | + .await?; |
| 63 | + metrics.flush_io(); |
| 64 | + stats.all_times.insert( |
| 65 | + "scalar_segment_prepare_time".into(), |
| 66 | + started.elapsed().as_nanos().min(usize::MAX as u128) as usize, |
| 67 | + ); |
| 68 | + if let Some(reason) = reason { |
| 69 | + stats |
| 70 | + .all_counts |
| 71 | + .insert("scalar_segment_fallbacks".into(), 1); |
| 72 | + stats |
| 73 | + .all_counts |
| 74 | + .insert(format!("scalar_segment_fallback_{reason}"), 1); |
| 75 | + } |
| 76 | + for (name, count) in plan_metrics.clone_inner().iter_counts() { |
| 77 | + let name = name.as_ref(); |
| 78 | + match name { |
| 79 | + "iops" => stats.iops += count.value(), |
| 80 | + "requests" => stats.requests += count.value(), |
| 81 | + "bytes_read" => stats.bytes_read += count.value(), |
| 82 | + "indices_loaded" => stats.indices_loaded += count.value(), |
| 83 | + "parts_loaded" => stats.parts_loaded += count.value(), |
| 84 | + "index_comparisons" => stats.index_comparisons += count.value(), |
| 85 | + _ => *stats.all_counts.entry(name.to_string()).or_default() += count.value(), |
| 86 | + } |
| 87 | + } |
| 88 | + if let Some(callback) = self.callback { |
| 89 | + // Preserve the callback's once-per-successfully-exhausted-stream contract. |
| 90 | + // Candidate work is not part of the underlying reader's plan metrics. |
| 91 | + reader.scan_stats_callback(Arc::new(move |read| { |
| 92 | + let mut combined = read.clone(); |
| 93 | + combined.iops += stats.iops; |
| 94 | + combined.requests += stats.requests; |
| 95 | + combined.bytes_read += stats.bytes_read; |
| 96 | + combined.indices_loaded += stats.indices_loaded; |
| 97 | + combined.parts_loaded += stats.parts_loaded; |
| 98 | + combined.index_comparisons += stats.index_comparisons; |
| 99 | + for (name, value) in &stats.all_counts { |
| 100 | + *combined.all_counts.entry(name.clone()).or_default() += value; |
| 101 | + } |
| 102 | + for (name, value) in &stats.all_times { |
| 103 | + *combined.all_times.entry(name.clone()).or_default() += value; |
| 104 | + } |
| 105 | + callback(&combined); |
| 106 | + })); |
| 107 | + } |
| 108 | + Ok(reader) |
| 109 | + } |
| 110 | + |
| 111 | + async fn configure_candidates( |
| 112 | + &self, |
| 113 | + reader: &mut Scanner, |
| 114 | + metrics: &IndexMetrics, |
| 115 | + stats: &mut ExecutionSummaryCounts, |
| 116 | + ) -> Result<Option<&'static str>> { |
| 117 | + let fragments = self.dataset.get_fragments(); |
| 118 | + let visible: HashSet<u64> = fragments.iter().map(|f| f.id() as u64).collect(); |
| 119 | + if self.fragment_ids.iter().any(|id| !visible.contains(id)) { |
| 120 | + return Err(invalid( |
| 121 | + "scalar segment fragment_ids contains a fragment absent from the dataset snapshot", |
| 122 | + )); |
| 123 | + } |
| 124 | + let indices = self.dataset.load_indices().await?; |
| 125 | + let index_meta = indices |
| 126 | + .iter() |
| 127 | + .find(|i| i.uuid == self.segment_uuid) |
| 128 | + .ok_or_else(|| { |
| 129 | + invalid(format!( |
| 130 | + "scalar index segment {} is absent from the dataset snapshot", |
| 131 | + self.segment_uuid |
| 132 | + )) |
| 133 | + })?; |
| 134 | + let field_id = index_meta |
| 135 | + .keyed_field() |
| 136 | + .ok_or_else(|| invalid("scalar segment must index a single key field"))?; |
| 137 | + let field = |
| 138 | + self.dataset.schema().field_by_id(field_id).ok_or_else(|| { |
| 139 | + invalid("scalar segment key field is absent from the dataset schema") |
| 140 | + })?; |
| 141 | + // Keep V1 to flat scalar fields. A dotted name is not sufficient to prove |
| 142 | + // the field path of an evolved or nested schema. |
| 143 | + if !self |
| 144 | + .dataset |
| 145 | + .schema() |
| 146 | + .fields |
| 147 | + .iter() |
| 148 | + .any(|f| f.id == field.id) |
| 149 | + { |
| 150 | + return Ok(Some("nested_field")); |
| 151 | + } |
| 152 | + let scope: HashSet<u64> = self.fragment_ids.iter().copied().collect(); |
| 153 | + let Some(coverage) = index_meta.fragment_bitmap.as_ref() else { |
| 154 | + return Ok(Some("unknown_coverage")); |
| 155 | + }; |
| 156 | + if self |
| 157 | + .fragment_ids |
| 158 | + .iter() |
| 159 | + .any(|id| u32::try_from(*id).map_or(true, |id| !coverage.contains(id))) |
| 160 | + { |
| 161 | + // Scan the ENTIRE explicit read domain, not just the covered part. |
| 162 | + return Ok(Some("partial_coverage")); |
| 163 | + } |
| 164 | + if fragments |
| 165 | + .iter() |
| 166 | + .filter(|f| scope.contains(&(f.id() as u64))) |
| 167 | + .any(|f| !f.metadata().overlays.is_empty() || f.metadata().physical_rows.is_none()) |
| 168 | + { |
| 169 | + return Ok(Some("fragment_state")); |
| 170 | + } |
| 171 | + // Fragment reuse can change the domain of an old segment. Until its |
| 172 | + // coverage mapping is handled here, preserve correctness with a scoped scan. |
| 173 | + if self.dataset.frag_reuse_index_uuid().await.is_some() { |
| 174 | + return Ok(Some("fragment_reuse")); |
| 175 | + } |
| 176 | + let Some(filter) = reader.get_expr_filter()? else { |
| 177 | + return Ok(Some("no_filter")); |
| 178 | + }; |
| 179 | + let planner = Planner::new(Arc::new(self.dataset.schema().into())); |
| 180 | + let index_info = self.dataset.scalar_index_info().await?; |
| 181 | + let filter_plan = planner.create_filter_plan(filter, &index_info, true)?; |
| 182 | + let Some(search) = filter_plan |
| 183 | + .index_query |
| 184 | + .as_ref() |
| 185 | + .and_then(|expr| driver(expr, &index_meta.name)) |
| 186 | + else { |
| 187 | + return Ok(Some("no_driver")); |
| 188 | + }; |
| 189 | + if search.column != field.name { |
| 190 | + return Ok(Some("field_path")); |
| 191 | + } |
| 192 | + let index = self |
| 193 | + .dataset |
| 194 | + .open_scalar_index(&search.column, &self.segment_uuid, metrics) |
| 195 | + .await?; |
| 196 | + if !matches!(index.index_type(), IndexType::BTree | IndexType::Bitmap) { |
| 197 | + return Ok(Some("index_type")); |
| 198 | + } |
| 199 | + // External masks use _rowid, not necessarily physical row addresses. |
| 200 | + if index.results_are_row_addresses() && self.dataset.manifest.uses_stable_row_ids() { |
| 201 | + return Ok(Some("row_id_domain")); |
| 202 | + } |
| 203 | + let started = Instant::now(); |
| 204 | + let result = index.search(search.query.as_ref(), metrics).await?; |
| 205 | + stats.all_times.insert( |
| 206 | + "scalar_segment_search_time".into(), |
| 207 | + started.elapsed().as_nanos().min(usize::MAX as u128) as usize, |
| 208 | + ); |
| 209 | + stats |
| 210 | + .all_counts |
| 211 | + .insert("scalar_segments_searched".into(), 1); |
| 212 | + let SearchResult::Exact(rows) = result else { |
| 213 | + return Ok(Some("inexact_result")); |
| 214 | + }; |
| 215 | + stats.all_counts.insert( |
| 216 | + "scalar_segment_candidate_rows".into(), |
| 217 | + rows.len().unwrap_or(0) as usize, |
| 218 | + ); |
| 219 | + // Do not truncate candidates at LIMIT. The reader evaluates the complete |
| 220 | + // filter before applying its existing limit/offset operators. |
| 221 | + reader.with_row_addr_prefilter(RowAddrMask::from_allowed(rows.selected_rows().clone())); |
| 222 | + Ok(None) |
| 223 | + } |
| 224 | +} |
0 commit comments