Skip to content

Commit bca783e

Browse files
committed
scalar index segment
1 parent 373c2bb commit bca783e

7 files changed

Lines changed: 651 additions & 0 deletions

File tree

docs/scalar-segment-scans.md

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
# Scalar index segment scans
2+
3+
An ordinary scanner can use one physical BTree/Bitmap segment to generate
4+
candidates, then read those candidates with the complete scanner filter. This
5+
does not run a global search of the other segments of the logical index. It does
6+
not subdivide a physical segment or make its own index search incremental.
7+
8+
## Configuring a task
9+
10+
Open a fixed dataset version. Select the physical index UUID from that version's
11+
metadata and pass the task's complete fragment domain explicitly:
12+
13+
```c
14+
LanceScanner *scanner = lance_scanner_new(dataset, columns, full_filter_sql);
15+
/* Check every return value in production. */
16+
lance_scanner_set_fragment_ids(scanner, fragment_ids, fragment_count);
17+
lance_scanner_set_scalar_index_segment(scanner, segment_uuid_16_bytes);
18+
lance_scanner_set_limit(scanner, 20000);
19+
/* The scanner-owning thread calls lance_scanner_next as usual. */
20+
```
21+
22+
SQL, Substrait and additional SQL filters keep their existing precedence and AND
23+
composition. The caller does not supply a separate driver predicate: Lance-C
24+
uses the typed filter planner and selects a necessary indexed leaf belonging to
25+
the requested logical index. It only descends through AND, never through OR or
26+
NOT. It then searches the selected UUID and applies the complete filter while
27+
reading candidates with automatic scalar-index planning disabled.
28+
29+
Each task's fragment IDs define its result domain, including on fallback. A
30+
distributed planner must assign disjoint domains whose union covers the intended
31+
scan. Unindexed fragments need their own tasks, or an explicit domain including
32+
them (which causes that task to use fallback). Merely listing indexed segments
33+
does not include appended, unindexed data automatically.
34+
35+
An unknown UUID, absent fragment or invalid option combination is an error. A
36+
known segment with incomplete/unknown coverage, no suitable driver, unsupported
37+
index type, nested key, overlays, fragment reuse, non-exact results or unsupported
38+
row-ID domain falls back to a non-indexed scan of the entire explicit domain.
39+
I/O and corruption errors are propagated, not converted to empty results or
40+
successful fallback.
41+
42+
The first implementation supports live-row ordinary scans and cannot be combined
43+
with vector/FTS queries. Physical row-address
44+
results on stable-row-ID datasets currently fall back; results already expressed
45+
in the correct row-ID domain use the candidate path. Deletes and all remaining
46+
predicates are handled by the ordinary reader. No candidate-count limit is
47+
applied: LIMIT/OFFSET remain after the scanner's complete filter.
48+
49+
If the host has additional predicates outside Lance, do not set a local limit
50+
before those predicates. Never divide the global limit by the number of tasks.
51+
Global OFFSET belongs to the coordinator, not independently to each task.
52+
53+
## Stopping after the host limit
54+
55+
This mode uses the existing scanner/stream lifecycle. Once the host has enough
56+
rows, it stops requesting further batches and closes the scanner after any active
57+
call has returned. Do not call `lance_scanner_close` concurrently with `next`.
58+
Exported Arrow streams remain owned by the caller and must also be released after
59+
their active consumers have finished.
60+
61+
A host stop flag does not interrupt an in-progress `lance_scanner_next`: current
62+
index evaluation or I/O may finish before the host observes stop and closes the
63+
stream. No separate cancellation signal or thread is introduced. The host remains
64+
responsible for enforcing the global LIMIT across concurrent tasks.
65+
66+
## Memory and statistics
67+
68+
Candidate masks stay in Rust, and record batches are streamed. Each active task
69+
can still hold a complete segment's candidate set; scanner I/O buffer size does
70+
not cap that allocation. Control task concurrency and physical segment size.
71+
72+
Successful exhaustion merges segment-search metrics into the existing statistics
73+
callback exactly once. New metrics include `scalar_segments_requested`,
74+
`scalar_segments_searched`, `scalar_segment_candidate_rows`,
75+
`scalar_segment_prepare_time`, `scalar_segment_search_time`, and
76+
`scalar_segment_fallback_*` reasons. `prepare_time` includes search time. Early
77+
release, cancellation and errors retain the existing callback contract: final
78+
statistics are not guaranteed. Metrics do not establish global task concurrency.

include/lance/lance.h

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1763,6 +1763,30 @@ int32_t lance_scanner_set_index_segments(
17631763
size_t len
17641764
);
17651765

1766+
/**
1767+
* Accelerate an ordinary scalar-filtered scan with one physical index segment.
1768+
* segment_uuid points to 16 UUID bytes in RFC 4122 order; NULL clears the setting.
1769+
* Must be configured before scanning. Requires explicit nonempty fragment_ids,
1770+
* which define BOTH the read and fallback domain, independently of the segment.
1771+
* Missing snapshot UUIDs / fragment IDs are errors. Extra segment coverage is
1772+
* excluded by fragment_ids; incomplete coverage falls back to a full filtered
1773+
* scan of those fragment_ids. Callers distributing work must assign disjoint
1774+
* fragment domains and separately include any unindexed data they wish to read.
1775+
*
1776+
* BTree/Bitmap searches use a necessary AND-conjunct of the full scanner filter
1777+
* on the selected logical index. All predicates are reapplied during candidate
1778+
* reads; other scalar indices are disabled. OR/NOT-only filters, overlays,
1779+
* fragment reuse, unsupported index types / result domains
1780+
* and missing coverage use the same domain without an index. No filter also
1781+
* falls back. LIMIT/OFFSET apply after the complete scanner filter, never to the
1782+
* unfiltered candidate set. Vector/FTS queries are rejected.
1783+
*
1784+
* UUID bytes are copied. Metadata and final option compatibility are validated
1785+
* when creating the stream. Index corruption or I/O failures remain errors.
1786+
*/
1787+
int32_t lance_scanner_set_scalar_index_segment(
1788+
LanceScanner* scanner, const uint8_t* segment_uuid);
1789+
17661790
/* ─── Full-text search (Phase 2) ─── */
17671791

17681792
/**

include/lance/lance.hpp

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1276,6 +1276,20 @@ class Scanner {
12761276
return *this;
12771277
}
12781278

1279+
/// Restrict scalar candidate generation to one segment; fragment_ids is
1280+
/// required and defines the complete read/fallback domain. See lance.h.
1281+
Scanner& scalar_index_segment(const std::array<uint8_t, 16>& segment_uuid) {
1282+
if (lance_scanner_set_scalar_index_segment(handle_.get(), segment_uuid.data()) != 0)
1283+
check_error();
1284+
return *this;
1285+
}
1286+
1287+
Scanner& clear_scalar_index_segment() {
1288+
if (lance_scanner_set_scalar_index_segment(handle_.get(), nullptr) != 0)
1289+
check_error();
1290+
return *this;
1291+
}
1292+
12791293
/// Restrict scan to specific fragment IDs.
12801294
Scanner& fragment_ids(const uint64_t* ids, size_t len) {
12811295
if (lance_scanner_set_fragment_ids(handle_.get(), ids, len) != 0)

src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ mod index_segment;
3939
mod merge_insert;
4040
mod restore;
4141
pub mod runtime;
42+
mod scalar_segment;
4243
mod scanner;
4344
mod session;
4445
pub mod stream_guard;

src/scalar_segment.rs

Lines changed: 224 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,224 @@
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

Comments
 (0)