use std::collections::HashMap;
use std::sync::Arc;
use arrow_array::Array;
use arrow_schema::{DataType, Schema as ArrowSchema};
use datafusion_physical_plan::expressions::Column;
use datafusion_physical_plan::projection::ProjectionExec;
use datafusion_physical_plan::{ExecutionPlan, PhysicalExpr};
use lance::Dataset;
use lance::dataset::mem_wal::scanner::InMemoryMemTables;
use lance::dataset::mem_wal::{
DatasetMemWalExt, LsmScanner, ShardManifestStore, ShardSnapshot, ShardWriterConfig,
};
use lance_index::mem_wal::{MemWalIndexDetails, ShardManifest};
use uuid::Uuid;
use super::NativeTable;
use crate::DistanceType;
use crate::error::{Error, Result};
use crate::query::{DEFAULT_TOP_K, QueryFilter, Select, VectorQueryRequest};
use crate::utils::default_vector_column;
const LSM_OVERFETCH_FACTOR: f64 = 2.0;
pub(super) async fn create_lsm_plan(
table: &NativeTable,
ds_ref: Arc<Dataset>,
query: VectorQueryRequest,
) -> Result<Arc<dyn ExecutionPlan>> {
reject_unsupported(&query)?;
if table.dataset.time_travel_version().is_some() {
return Err(Error::NotSupported {
message: "the MemWAL LSM scanner cannot read from a time-traveled dataset version; set use_lsm(false) to read the base table at this version".to_string(),
});
}
let details = ds_ref
.mem_wal_index_details()
.await?
.ok_or_else(|| Error::Runtime {
message: "the MemWAL LSM write spec disappeared during read planning".to_string(),
})?;
let pk_columns = pk_columns(&ds_ref)?;
let arm_index = arm_maintained_index_name(&ds_ref, &query, &details).await?;
let (snapshots, in_memory) =
build_read_context(table, &ds_ref, &details, arm_index.as_deref()).await?;
let limit = query.base.limit;
let offset = query.base.offset;
let plan = if !query.query_vector.is_empty() {
vector_plan(
&ds_ref,
&query,
&details,
pk_columns.clone(),
snapshots,
in_memory,
limit,
offset,
)
.await?
} else if let Some(fts) = &query.base.full_text_search {
fts_plan(
&ds_ref,
fts.clone(),
&query,
&details,
pk_columns.clone(),
snapshots,
in_memory,
limit,
offset,
)
.await?
} else {
plain_plan(
&ds_ref,
&query,
pk_columns.clone(),
snapshots,
in_memory,
limit,
offset,
)
.await?
};
restore_projection(plan, &query, &pk_columns)
}
fn reject_unsupported(query: &VectorQueryRequest) -> Result<()> {
let unsupported = |what: &str| {
Err(Error::NotSupported {
message: format!(
"the MemWAL LSM scanner does not support {what}; set use_lsm(false) to read the base table only (results will exclude un-compacted MemWAL data)"
),
})
};
if query.query_vector.len() > 1 {
return unsupported("multiple query vectors");
}
if !query.query_vector.is_empty() && query.base.full_text_search.is_some() {
return unsupported("hybrid (vector + full-text) search");
}
if query.base.with_row_id {
return unsupported("with_row_id (the LSM scanner exposes _rowaddr, not a stable _rowid)");
}
if query.base.reranker.is_some() {
return unsupported("reranking / hybrid search");
}
if query.base.order_by.is_some() {
return unsupported("order_by");
}
if !query.query_vector.is_empty() {
if query.lower_bound.is_some() || query.upper_bound.is_some() {
return unsupported("distance_range on vector search");
}
if !query.use_index {
return unsupported(
"use_index(false) / brute-force vector search (the LSM base arm is index-only)",
);
}
}
if (!query.query_vector.is_empty() || query.base.full_text_search.is_some())
&& !query.base.prefilter
{
return unsupported(
"postfilter on vector or full-text search (the LSM scanner always prefilters)",
);
}
match &query.base.select {
Select::All | Select::Columns(_) => {}
Select::Dynamic(_) | Select::Expr(_) => return unsupported("dynamic column projection"),
}
if let Some(QueryFilter::Substrait(_)) = &query.base.filter {
return unsupported("Substrait filters");
}
if let Some(QueryFilter::Datafusion(expr)) = &query.base.filter
&& expr
.column_refs()
.iter()
.any(|c| c.name == "_rowid" || c.name == "_rowoffset")
{
return unsupported(
"take by row id or row offset (the LSM scanner has no stable _rowid / _rowoffset)",
);
}
Ok(())
}
fn pk_columns(dataset: &Dataset) -> Result<Vec<String>> {
let pk: Vec<String> = dataset
.schema()
.unenforced_primary_key()
.iter()
.map(|f| f.name.clone())
.collect();
if pk.is_empty() {
return Err(Error::InvalidInput {
message:
"the MemWAL LSM scanner requires an unenforced primary key, but the table has none"
.to_string(),
});
}
Ok(pk)
}
fn exclusion_watermarks(
details: &MemWalIndexDetails,
index_name: Option<&str>,
) -> HashMap<Uuid, u64> {
let mut exclude: HashMap<Uuid, u64> = HashMap::new();
for entry in &details.compacted_sstables {
let mut watermark = entry.generation;
if let Some(name) = index_name
&& let Some(caught_up) = details
.index_catchup
.iter()
.find(|icp| icp.index_name == name)
.and_then(|icp| icp.caught_up_generation_for_shard(&entry.shard_id))
{
watermark = watermark.min(caught_up);
}
exclude.entry(entry.shard_id).or_insert(watermark);
}
exclude
}
async fn build_read_context(
table: &NativeTable,
dataset: &Dataset,
details: &MemWalIndexDetails,
index_name: Option<&str>,
) -> Result<(Vec<ShardSnapshot>, HashMap<Uuid, InMemoryMemTables>)> {
let exclude = exclusion_watermarks(details, index_name);
let shard_ids = dataset.list_mem_wal_latest_shard_ids().await?;
let store = dataset.object_store(None).await?;
let base_path = dataset.branch_location().path;
let scan_batch_size = ShardWriterConfig::default().manifest_scan_batch_size;
let mut snapshots: Vec<ShardSnapshot> = Vec::new();
for shard_id in shard_ids {
let manifest_store =
ShardManifestStore::new(store.clone(), &base_path, shard_id, scan_batch_size);
if let Some(manifest) = manifest_store.read_latest().await? {
snapshots.push(snapshot_from_manifest(shard_id, &manifest, &exclude));
}
}
let wal_only = details
.writer_config_defaults
.get("enable_memtable")
.map(|v| v == "false")
.unwrap_or(false);
let mut in_memory: HashMap<Uuid, InMemoryMemTables> = HashMap::new();
if !wal_only
&& let Some((shard_id, manifest, memtables)) =
table.dataset.shard_writer().read_snapshot().await?
{
if let Some(manifest) = manifest {
let snapshot = snapshot_from_manifest(shard_id, &manifest, &exclude);
match snapshots.iter_mut().find(|s| s.shard_id == shard_id) {
Some(existing) => *existing = snapshot,
None => snapshots.push(snapshot),
}
}
if let Some(memtables) = memtables {
in_memory.insert(shard_id, memtables);
}
}
Ok((snapshots, in_memory))
}
fn snapshot_from_manifest(
shard_id: Uuid,
manifest: &ShardManifest,
compacted: &HashMap<Uuid, u64>,
) -> ShardSnapshot {
let mut snapshot = ShardSnapshot::new(shard_id)
.with_spec_id(manifest.shard_spec_id)
.with_current_generation(manifest.current_generation);
let watermark = compacted.get(&shard_id).copied();
for sstable in &manifest.sstables {
if watermark.is_some_and(|w| sstable.generation <= w) {
continue;
}
snapshot = snapshot.with_sstable(sstable.generation, sstable.path.clone());
}
snapshot
}
fn selected_columns(query: &VectorQueryRequest) -> Option<Vec<String>> {
match &query.base.select {
Select::Columns(columns) => Some(columns.clone()),
_ => None,
}
}
fn as_i64(value: Option<usize>) -> Option<i64> {
value.map(|v| v as i64)
}
fn base_scanner(
dataset: &Dataset,
query: &VectorQueryRequest,
pk_columns: Vec<String>,
snapshots: Vec<ShardSnapshot>,
in_memory: HashMap<Uuid, InMemoryMemTables>,
) -> Result<LsmScanner> {
let mut scanner = LsmScanner::new(Arc::new(dataset.clone()), snapshots, pk_columns);
for (shard_id, memtables) in in_memory {
scanner = scanner.with_in_memory_memtables(shard_id, memtables);
}
if let Some(columns) = selected_columns(query) {
let refs: Vec<&str> = columns.iter().map(String::as_str).collect();
scanner = scanner.project(&refs)?;
}
if let Some(filter) = &query.base.filter {
scanner = match filter {
QueryFilter::Sql(sql) => scanner.filter(sql)?,
QueryFilter::Datafusion(expr) => scanner.filter_expr(expr.clone()),
QueryFilter::Substrait(_) => {
return Err(Error::NotSupported {
message: "the MemWAL LSM scanner does not support Substrait filters; set use_lsm(false) to read the base table only".to_string(),
});
}
};
}
Ok(scanner)
}
async fn plain_plan(
dataset: &Dataset,
query: &VectorQueryRequest,
pk_columns: Vec<String>,
snapshots: Vec<ShardSnapshot>,
in_memory: HashMap<Uuid, InMemoryMemTables>,
limit: Option<usize>,
offset: Option<usize>,
) -> Result<Arc<dyn ExecutionPlan>> {
let scanner = base_scanner(dataset, query, pk_columns, snapshots, in_memory)?
.limit(as_i64(limit), as_i64(offset))?;
Ok(scanner.create_plan().await?)
}
#[allow(clippy::too_many_arguments)]
async fn fts_plan(
dataset: &Dataset,
fts: lance_index::scalar::FullTextSearchQuery,
query: &VectorQueryRequest,
details: &MemWalIndexDetails,
pk_columns: Vec<String>,
snapshots: Vec<ShardSnapshot>,
in_memory: HashMap<Uuid, InMemoryMemTables>,
limit: Option<usize>,
offset: Option<usize>,
) -> Result<Arc<dyn ExecutionPlan>> {
let columns: Vec<String> = fts.columns().into_iter().collect();
if columns.len() > 1 {
return Err(Error::NotSupported {
message: "the MemWAL LSM scanner full-text search supports a single column; set use_lsm(false) to read the base table only".to_string(),
});
}
let column = columns.first().ok_or_else(|| Error::NotSupported {
message: "the MemWAL LSM scanner full-text search requires an explicit FTS column"
.to_string(),
})?;
if !index_maintained(
dataset,
column,
&details.maintained_indexes,
"InvertedIndexDetails",
)
.await?
{
return Err(Error::NotSupported {
message: format!(
"the MemWAL LSM scanner full-text search requires the FTS index on '{column}' to be maintained by the write spec (LsmWriteSpec::with_maintained_indexes); otherwise un-compacted documents are omitted. set use_lsm(false) to read the base table only"
),
});
}
let scanner = base_scanner(dataset, query, pk_columns, snapshots, in_memory)?
.with_overfetch_factor(LSM_OVERFETCH_FACTOR)
.full_text_search(fts)?
.limit(as_i64(limit), as_i64(offset))?;
Ok(scanner.create_plan().await?)
}
async fn index_maintained(
dataset: &Dataset,
column: &str,
maintained: &[String],
type_url_suffix: &str,
) -> Result<bool> {
use lance::index::DatasetIndexExt;
let Some(field) = dataset.schema().field(column) else {
return Ok(false);
};
let indices = dataset.load_indices().await?;
Ok(indices.iter().any(|idx| {
idx.fields.contains(&field.id)
&& maintained.iter().any(|m| m == &idx.name)
&& idx
.index_details
.as_ref()
.is_some_and(|d| d.type_url.ends_with(type_url_suffix))
}))
}
async fn arm_maintained_index_name(
dataset: &Dataset,
query: &VectorQueryRequest,
details: &MemWalIndexDetails,
) -> Result<Option<String>> {
use lance::index::DatasetIndexExt;
let (column, type_url_suffix, arm) = if !query.query_vector.is_empty() {
let arrow_schema = ArrowSchema::from(dataset.schema());
let column = match &query.column {
Some(column) => column.clone(),
None => {
let dim = query.query_vector.first().map(|v| v.len() as i32);
default_vector_column(&arrow_schema, dim)?
}
};
(column, "VectorIndexDetails", "vector")
} else if let Some(fts) = &query.base.full_text_search {
match fts.columns().into_iter().next() {
Some(column) => (column, "InvertedIndexDetails", "full-text"),
None => return Ok(None),
}
} else {
return Ok(None);
};
let Some(field) = dataset.schema().field(&column) else {
return Ok(None);
};
let indices = dataset.load_indices().await?;
let segment_names: Vec<String> = indices
.iter()
.filter(|idx| {
idx.fields.contains(&field.id)
&& idx
.index_details
.as_ref()
.is_some_and(|d| d.type_url.ends_with(type_url_suffix))
})
.map(|idx| idx.name.clone())
.collect();
resolve_single_index(segment_names, &details.maintained_indexes, arm, &column)
}
fn resolve_single_index(
mut names: Vec<String>,
maintained: &[String],
arm: &str,
column: &str,
) -> Result<Option<String>> {
names.sort();
names.dedup();
if names.len() > 1 {
return Err(Error::NotSupported {
message: format!(
"the MemWAL LSM scanner cannot resolve the {arm} index catch-up watermark for '{column}': it has multiple {arm} indexes; set use_lsm(false) to read the base table only"
),
});
}
Ok(names
.into_iter()
.next()
.filter(|name| maintained.contains(name)))
}
fn restore_projection(
plan: Arc<dyn ExecutionPlan>,
query: &VectorQueryRequest,
pk_columns: &[String],
) -> Result<Arc<dyn ExecutionPlan>> {
let Select::Columns(selected) = &query.base.select else {
return Ok(plan);
};
let schema = plan.schema();
let keep: Vec<(Arc<dyn PhysicalExpr>, String)> = schema
.fields()
.iter()
.enumerate()
.filter(|(_, f)| {
selected.iter().any(|c| c == f.name()) || !pk_columns.iter().any(|pk| pk == f.name())
})
.map(|(i, f)| {
(
Arc::new(Column::new(f.name(), i)) as Arc<dyn PhysicalExpr>,
f.name().clone(),
)
})
.collect();
if keep.len() == schema.fields().len() {
return Ok(plan);
}
Ok(Arc::new(ProjectionExec::try_new(keep, plan)?))
}
#[allow(clippy::too_many_arguments)]
async fn vector_plan(
dataset: &Dataset,
query: &VectorQueryRequest,
details: &MemWalIndexDetails,
pk_columns: Vec<String>,
snapshots: Vec<ShardSnapshot>,
in_memory: HashMap<Uuid, InMemoryMemTables>,
limit: Option<usize>,
offset: Option<usize>,
) -> Result<Arc<dyn ExecutionPlan>> {
let query_vector = query
.query_vector
.first()
.cloned()
.ok_or_else(|| Error::InvalidInput {
message: "vector search requires a query vector".to_string(),
})?;
let arrow_schema = ArrowSchema::from(dataset.schema());
let column = match &query.column {
Some(column) => column.clone(),
None => default_vector_column(&arrow_schema, Some(query_vector.len() as i32))?,
};
if !index_maintained(
dataset,
&column,
&details.maintained_indexes,
"VectorIndexDetails",
)
.await?
{
return Err(Error::NotSupported {
message: format!(
"the MemWAL LSM scanner requires the vector index on '{column}' to be maintained by the write spec (LsmWriteSpec::with_maintained_indexes); otherwise compacted rows not yet re-indexed are omitted. set use_lsm(false) to read the base table only"
),
});
}
if is_binary_vector_column(&arrow_schema, &column) {
return Err(Error::NotSupported {
message: "the MemWAL LSM scanner does not support binary (uint8) vector search; set use_lsm(false) to read the base table only".to_string(),
});
}
let distance_type = resolve_distance_type(dataset, query, &column).await?;
let k = limit.unwrap_or(DEFAULT_TOP_K).max(1);
let mut scanner = base_scanner(dataset, query, pk_columns, snapshots, in_memory)?
.with_overfetch_factor(LSM_OVERFETCH_FACTOR)
.nearest(&column, query_vector.as_ref(), k)?
.nprobes(query.minimum_nprobes)
.distance_metric(distance_type.into());
if let Some(refine_factor) = query.refine_factor {
scanner = scanner.refine(refine_factor);
}
scanner = scanner.limit(as_i64(limit), as_i64(offset))?;
Ok(scanner.create_plan().await?)
}
fn is_binary_vector_column(schema: &ArrowSchema, column: &str) -> bool {
matches!(
schema.field_with_name(column).map(|f| f.data_type()),
Ok(DataType::FixedSizeList(field, _)) if matches!(field.data_type(), DataType::UInt8)
)
}
async fn resolve_distance_type(
dataset: &Dataset,
query: &VectorQueryRequest,
column: &str,
) -> Result<DistanceType> {
if let Some(dt) = query.distance_type {
return Ok(dt);
}
use lance::index::{DatasetIndexExt, DatasetIndexInternalExt};
use lance_index::metrics::NoOpMetricsCollector;
let field = dataset.schema().field(column);
if let Some(field) = field {
let indices = dataset.load_indices().await?;
for index in indices.iter() {
if index.fields.contains(&field.id)
&& let Ok(vector_index) = dataset
.open_vector_index(column, &index.uuid, &NoOpMetricsCollector)
.await
{
return Ok(vector_index.metric_type().into());
}
}
}
Ok(DistanceType::L2)
}
#[cfg(test)]
mod tests {
use super::*;
use lance_index::mem_wal::{CompactedSsTable, IndexCatchupProgress};
#[test]
fn exclusion_watermark_gates_on_lagging_index_catchup() {
let shard = Uuid::from_u128(1);
let details = MemWalIndexDetails {
compacted_sstables: vec![CompactedSsTable::new(shard, 5)],
index_catchup: vec![IndexCatchupProgress::new(
"fts_idx".to_string(),
vec![CompactedSsTable::new(shard, 2)],
)],
maintained_indexes: vec!["fts_idx".to_string()],
..Default::default()
};
assert_eq!(exclusion_watermarks(&details, None).get(&shard), Some(&5));
assert_eq!(
exclusion_watermarks(&details, Some("fts_idx")).get(&shard),
Some(&2)
);
assert_eq!(
exclusion_watermarks(&details, Some("caught_up_idx")).get(&shard),
Some(&5)
);
}
#[test]
fn resolve_single_index_dedupes_segments() {
let maintained = vec!["fts_idx".to_string()];
assert_eq!(
resolve_single_index(
vec!["fts_idx".to_string(), "fts_idx".to_string()],
&maintained,
"full-text",
"text"
)
.unwrap(),
Some("fts_idx".to_string())
);
assert!(
resolve_single_index(
vec!["fts_a".to_string(), "fts_b".to_string()],
&maintained,
"full-text",
"text"
)
.is_err()
);
assert_eq!(
resolve_single_index(vec!["other".to_string()], &maintained, "full-text", "text")
.unwrap(),
None
);
}
}