use std::collections::HashMap;
use std::sync::Arc;
use arrow_array::RecordBatch;
use bytes::Bytes;
use datafusion::physical_plan::SendableRecordBatchStream;
use lance_core::cache::LanceCache;
use lance_core::utils::deletion::DeletionVector;
use lance_core::{Error, Result};
use lance_file::version::ConcreteFileVersion;
use lance_index::mem_wal::{ShardManifest, SsTable};
use lance_io::object_store::{ObjectStore, ObjectStoreParams};
use lance_table::format::IndexMetadata;
use lance_table::io::commit::write_manifest_file_to_path;
use lance_table::io::deletion::write_deletion_file;
use object_store::ObjectStoreExt;
use object_store::path::Path;
use roaring::RoaringBitmap;
use tracing::{debug, info, instrument, warn};
use uuid::Uuid;
use super::super::index::{FlushContext, FlushOutcome, GenerationWrite, IndexStore, MemIndexSpec};
use super::super::memtable::MemTable;
use crate::Dataset;
use crate::dataset::builder::DatasetBuilder;
use crate::dataset::mem_wal::manifest::ShardManifestStore;
use crate::dataset::mem_wal::scanner::SsTableWarmer;
use crate::dataset::mem_wal::scanner::exec::{compute_pk_hash, validate_pk_types};
use crate::dataset::mem_wal::util::{derived_store_params, generate_random_hash, sstable_path};
use crate::dataset::write::InsertBuilder;
use crate::index::CreateIndexBuilder;
use crate::session::Session;
#[derive(Debug, Clone)]
pub struct FlushResult {
pub sstable: SsTable,
pub rows_flushed: usize,
pub covered_wal_entry_position: u64,
}
fn compute_dedup_deletions(batches: &[RecordBatch], pk_indices: &[usize]) -> RoaringBitmap {
let mut deleted = RoaringBitmap::new();
let mut latest: HashMap<u64, u32> = HashMap::new();
let mut offset: u32 = 0;
for batch in batches {
for row in 0..batch.num_rows() {
let pk_hash = compute_pk_hash(batch, pk_indices, row);
if let Some(previous) = latest.insert(pk_hash, offset) {
deleted.insert(previous);
}
offset += 1;
}
}
deleted
}
pub struct MemTableFlusher {
object_store: Arc<ObjectStore>,
base_path: Path,
base_uri: String,
shard_id: Uuid,
manifest_store: Arc<ShardManifestStore>,
warmer: Option<Arc<dyn SsTableWarmer>>,
store_params: Option<ObjectStoreParams>,
session: Option<Arc<Session>>,
}
#[derive(Clone, Copy)]
struct FlushedSize {
in_memory_bytes: Option<u64>,
physical_rows: Option<u64>,
primary_key_bytes: Option<u64>,
}
impl FlushedSize {
fn of(memtable: &MemTable) -> Self {
Self {
in_memory_bytes: Some(memtable.batch_store().row_bytes() as u64).filter(|b| *b > 0),
physical_rows: Some(memtable.row_count() as u64).filter(|r| *r > 0),
primary_key_bytes: Some(memtable.pk_bytes() as u64).filter(|b| *b > 0),
}
}
fn sstable(self, generation: u64, path: String) -> SsTable {
SsTable {
generation,
path,
in_memory_bytes: self.in_memory_bytes,
physical_rows: self.physical_rows,
primary_key_bytes: self.primary_key_bytes,
}
}
}
const TRAINING_BATCH_SIZE: usize = 8192;
impl MemTableFlusher {
pub fn new(
object_store: Arc<ObjectStore>,
base_path: Path,
base_uri: impl Into<String>,
shard_id: Uuid,
manifest_store: Arc<ShardManifestStore>,
) -> Self {
Self {
object_store,
base_path,
base_uri: base_uri.into(),
shard_id,
manifest_store,
warmer: None,
store_params: None,
session: None,
}
}
pub fn with_warmer(mut self, warmer: Option<Arc<dyn SsTableWarmer>>) -> Self {
self.warmer = warmer;
self
}
pub fn with_storage_context(
mut self,
store_params: Option<ObjectStoreParams>,
session: Option<Arc<Session>>,
) -> Self {
self.store_params = store_params;
self.session = session;
self
}
async fn open_base(&self) -> Result<Dataset> {
self.open_uri(&self.base_uri, self.store_params.clone())
.await
}
async fn open_generation(&self, uri: &str) -> Result<Dataset> {
self.open_uri(uri, self.store_params.as_ref().map(derived_store_params))
.await
}
async fn open_uri(
&self,
uri: &str,
store_params: Option<ObjectStoreParams>,
) -> Result<Dataset> {
let mut builder = DatasetBuilder::from_uri(uri);
if let Some(params) = store_params {
builder = builder.with_store_params(params);
}
if let Some(session) = &self.session {
builder = builder.with_session(session.clone());
}
builder.load().await
}
async fn warm_generation(&self, uri: &str) {
let Some(warmer) = &self.warmer else {
return;
};
if let Err(e) = warmer.warm(uri).await {
warn!("pre-commit warm failed for generation {uri}; committing cold: {e}");
}
}
fn path_to_uri(&self, path: &Path) -> String {
let path_str = path.as_ref();
let base_str = self.base_path.as_ref();
let relative = if let Some(stripped) = path_str.strip_prefix(base_str) {
stripped.trim_start_matches('/')
} else {
path_str
};
let base = self.base_uri.trim_end_matches('/');
if relative.is_empty() {
base.to_string()
} else {
format!("{}/{}", base, relative)
}
}
fn generation_target(&self, memtable: &MemTable) -> Result<(String, Path, Option<String>)> {
let generation = memtable.generation();
if let Some(target) = memtable.target() {
if target.generation != generation {
return Err(Error::internal(format!(
"memtable generation {} carries data target generation {}",
generation, target.generation
)));
}
return Ok((
target.generation_dir.clone(),
target.generation_path(&self.base_path, &self.shard_id),
Some(target.data_file_name.clone()),
));
}
let random_hash = generate_random_hash();
Ok((
format!("{}_gen_{}", random_hash, generation),
sstable_path(&self.base_path, &self.shard_id, &random_hash, generation),
None,
))
}
async fn base_storage_version(&self) -> Result<ConcreteFileVersion> {
match self.open_base().await {
Ok(dataset) => Ok(dataset.manifest().data_storage_format.lance_file_format()),
Err(Error::DatasetNotFound { .. }) => Ok(lance_file::version::stable_file_version()),
Err(e) => Err(e),
}
}
#[instrument(name = "mt_flush_storage", level = "info", skip_all, fields(shard_id = %self.shard_id, epoch, generation = memtable.generation(), row_count = memtable.row_count()))]
pub async fn flush(
&self,
memtable: &MemTable,
epoch: u64,
covered_wal_entry_position: u64,
durable: usize,
) -> Result<FlushResult> {
self.manifest_store.check_fenced(epoch).await?;
if let Some(published) = self
.already_published(memtable, covered_wal_entry_position)
.await?
{
return Ok(published);
}
if memtable.row_count() == 0 {
return Err(Error::invalid_input("Cannot flush empty MemTable"));
}
if !memtable.all_flushed_to_wal(durable) {
return Err(Error::invalid_input(
"MemTable has unflushed fragments - WAL flush required first",
));
}
let generation = memtable.generation();
let size = FlushedSize::of(memtable);
let (gen_folder_name, gen_path, preassigned_data_file_name) =
self.generation_target(memtable)?;
info!(
"Flushing MemTable generation {} to {} ({} rows, {} batches)",
generation,
gen_path,
memtable.row_count(),
memtable.batch_count()
);
let (rows_flushed, deleted) = self
.write_data_file(
&gen_path,
memtable,
preassigned_data_file_name.as_deref(),
self.base_storage_version().await?,
)
.await?;
if !deleted.is_empty() {
let uri = self.path_to_uri(&gen_path);
let dataset = self.open_generation(&uri).await?;
self.finalize_generation(&dataset, &deleted, None).await?;
}
let bloom_path = gen_path.clone().join("bloom_filter.bin");
self.write_bloom_filter(&bloom_path, memtable.bloom_filter())
.await?;
self.create_pk_index(&gen_path, memtable.indexes()).await?;
let warm_uri = self.path_to_uri(&gen_path);
self.warm_generation(&warm_uri).await;
let new_manifest = self
.update_manifest(
epoch,
generation,
&gen_folder_name,
covered_wal_entry_position,
size,
)
.await?;
info!(
"Flushed SSTable {} for shard {} (manifest version {})",
generation, self.shard_id, new_manifest.version
);
Ok(FlushResult {
sstable: size.sstable(generation, gen_folder_name),
rows_flushed,
covered_wal_entry_position,
})
}
#[instrument(name = "mt_write_data_file", level = "debug", skip_all, fields(path = %path))]
async fn write_data_file(
&self,
path: &Path,
memtable: &MemTable,
preassigned_data_file_name: Option<&str>,
storage_version: ConcreteFileVersion,
) -> Result<(usize, RoaringBitmap)> {
use arrow_array::RecordBatchIterator;
use crate::dataset::WriteParams;
if memtable.row_count() == 0 {
return Ok((0, RoaringBitmap::new()));
}
let batches = memtable.scan_batches().await?;
if batches.is_empty() {
return Ok((0, RoaringBitmap::new()));
}
let total_rows: usize = batches.iter().map(|b| b.num_rows()).sum();
let pk_columns: Vec<String> = memtable
.lance_schema()
.unenforced_primary_key()
.iter()
.map(|f| f.name.clone())
.collect();
let deleted = if pk_columns.is_empty() {
RoaringBitmap::new()
} else {
let schema = batches[0].schema();
validate_pk_types(schema.as_ref(), &pk_columns)?;
let pk_indices = pk_columns
.iter()
.map(|c| {
schema.index_of(c).map_err(|_| {
Error::invalid_input(format!(
"Primary key column '{}' not found in flush schema",
c
))
})
})
.collect::<Result<Vec<usize>>>()?;
compute_dedup_deletions(&batches, &pk_indices)
};
let uri = self.path_to_uri(path);
if let Some(preassigned_data_file_name) = preassigned_data_file_name {
match self.open_generation(&uri).await {
Ok(dataset) => {
let fragments = dataset.get_fragments();
let is_expected = fragments.len() == 1
&& fragments[0].metadata().physical_rows == Some(total_rows)
&& fragments[0]
.metadata()
.files
.iter()
.any(|file| file.path == preassigned_data_file_name);
if is_expected {
return Ok((total_rows, deleted));
}
return Err(Error::io(format!(
"generation {} already exists but does not describe the expected file {} and {} rows",
path, preassigned_data_file_name, total_rows
)));
}
Err(Error::DatasetNotFound { .. }) => {}
Err(error) => return Err(error),
}
}
let reader =
RecordBatchIterator::new(batches.into_iter().map(Ok), memtable.schema().clone());
let write_params = WriteParams {
max_rows_per_file: usize::MAX,
data_storage_version: Some(storage_version.to_selector()),
store_params: self.store_params.as_ref().map(derived_store_params),
session: self.session.clone(),
..Default::default()
};
let mut builder = InsertBuilder::new(uri.as_str())
.with_params(&write_params)
.with_table_field_ids();
if let Some(preassigned_data_file_name) = preassigned_data_file_name {
builder = builder.with_preassigned_data_file_name(preassigned_data_file_name);
}
builder.execute_stream(reader).await?;
Ok((total_rows, deleted))
}
async fn finalize_generation(
&self,
dataset: &Dataset,
deleted: &RoaringBitmap,
indexes: Option<Vec<IndexMetadata>>,
) -> Result<()> {
let indexes = indexes.filter(|i| !i.is_empty());
if deleted.is_empty() && indexes.is_none() {
return Ok(());
}
let mut manifest = dataset.manifest().clone();
let manifest_path = dataset.manifest_location().path.clone();
if !deleted.is_empty() {
let dv = DeletionVector::from(deleted.clone());
let deletion_file = write_deletion_file(
&dataset.base,
0, dataset.version().version,
&dv,
dataset.object_store.as_ref(),
)
.await?;
let fragments = Arc::make_mut(&mut manifest.fragments);
if let Some(fragment) = fragments.first_mut() {
fragment.deletion_file = deletion_file;
}
}
manifest.index_section = None;
manifest.transaction_section = None;
manifest.transaction_file = None;
write_manifest_file_to_path(
&self.object_store,
&mut manifest,
indexes,
&manifest_path,
None,
)
.await
.map_err(|e| Error::io(format!("Failed to write generation manifest: {}", e)))?;
Ok(())
}
async fn write_bloom_filter(
&self,
path: &Path,
bloom: &lance_core::utils::bloomfilter::sbbf::Sbbf,
) -> Result<()> {
let data = bloom.to_bytes();
self.object_store
.inner
.put(path, Bytes::from(data).into())
.await
.map_err(|e| Error::io(format!("Failed to write bloom filter: {}", e)))?;
Ok(())
}
#[instrument(name = "mt_flush_with_indexes", level = "info", skip_all, fields(shard_id = %self.shard_id, epoch, generation = memtable.generation(), row_count = memtable.row_count(), index_count = index_specs.len()))]
pub async fn flush_with_indexes(
&self,
memtable: &MemTable,
epoch: u64,
index_specs: &[MemIndexSpec],
covered_wal_entry_position: u64,
durable: usize,
) -> Result<FlushResult> {
self.manifest_store.check_fenced(epoch).await?;
if let Some(published) = self
.already_published(memtable, covered_wal_entry_position)
.await?
{
return Ok(published);
}
if memtable.row_count() == 0 {
return Err(Error::invalid_input("Cannot flush empty MemTable"));
}
if !memtable.all_flushed_to_wal(durable) {
return Err(Error::invalid_input(
"MemTable has unflushed fragments - WAL flush required first",
));
}
let generation = memtable.generation();
let size = FlushedSize::of(memtable);
let (gen_folder_name, gen_path, preassigned_data_file_name) =
self.generation_target(memtable)?;
info!(
"Flushing MemTable generation {} with indexes to {} ({} rows, {} batches)",
generation,
gen_path,
memtable.row_count(),
memtable.batch_count()
);
let storage_version = self.base_storage_version().await?;
let (total_rows, deleted) = self
.write_data_file(
&gen_path,
memtable,
preassigned_data_file_name.as_deref(),
storage_version,
)
.await?;
let uri = self.path_to_uri(&gen_path);
let mut dataset = self.open_generation(&uri).await?;
let all_indexes = self
.create_indexes(
&mut dataset,
&gen_path,
index_specs,
memtable.indexes(),
total_rows,
crate::dataset::versions::index_file_version(storage_version),
)
.await?;
info!(
generation,
index_count = all_indexes.len(),
"created indexes on SSTable"
);
self.create_pk_index(&gen_path, memtable.indexes()).await?;
self.finalize_generation(&dataset, &deleted, Some(all_indexes))
.await?;
let bloom_path = gen_path.clone().join("bloom_filter.bin");
self.write_bloom_filter(&bloom_path, memtable.bloom_filter())
.await?;
let warm_uri = self.path_to_uri(&gen_path);
self.warm_generation(&warm_uri).await;
let new_manifest = self
.update_manifest(
epoch,
generation,
&gen_folder_name,
covered_wal_entry_position,
size,
)
.await?;
info!(
"Flushed SSTable {} for shard {} (manifest version {})",
generation, self.shard_id, new_manifest.version
);
Ok(FlushResult {
sstable: size.sstable(generation, gen_folder_name),
rows_flushed: memtable.row_count(),
covered_wal_entry_position,
})
}
async fn create_indexes(
&self,
dataset: &mut Dataset,
gen_path: &Path,
index_specs: &[MemIndexSpec],
mem_indexes: Option<&IndexStore>,
total_rows: usize,
storage_version: ConcreteFileVersion,
) -> Result<Vec<IndexMetadata>> {
let Some(store) = mem_indexes else {
return Ok(vec![]);
};
let mut created = Vec::new();
for spec in index_specs {
let Some(index) = store.get_index(&spec.name) else {
debug!(index = %spec.name, "memtable does not hold this index; not flushed");
continue;
};
let generation = GenerationWrite {
path: gen_path,
object_store: &self.object_store,
dataset,
total_rows,
name: &spec.name,
storage_version,
};
let training_data = match index
.flush(&FlushContext {
batch_size: TRAINING_BATCH_SIZE,
generation: Some(&generation),
})
.await?
{
FlushOutcome::Skip => {
debug!(index = %spec.name, "index holds nothing to flush");
continue;
}
FlushOutcome::Wrote(index_meta) => {
if index_meta.name != spec.name || index_meta.fields != spec.field_ids {
return Err(Error::internal(format!(
"index '{}' on fields {:?} recorded its file as '{}' on fields {:?}",
spec.name, spec.field_ids, index_meta.name, index_meta.fields
)));
}
created.push(*index_meta);
continue;
}
FlushOutcome::TrainingData(stream) => Some(stream),
FlushOutcome::BuildFromGeneration => None,
};
created.push(build_scalar_index(dataset, spec, training_data).await?);
}
Ok(created)
}
async fn create_pk_index(
&self,
gen_path: &Path,
mem_indexes: Option<&IndexStore>,
) -> Result<()> {
use datafusion::physical_plan::SendableRecordBatchStream;
use datafusion::physical_plan::stream::RecordBatchStreamAdapter;
use lance_index::scalar::btree::train_btree_index;
use lance_index::scalar::lance_format::LanceIndexStore;
use crate::dataset::mem_wal::util::pk_index_path;
let Some(registry) = mem_indexes else {
return Ok(());
};
let batches = registry.pk_training_batches(TRAINING_BATCH_SIZE)?;
if batches.is_empty() {
return Ok(());
}
let schema = batches[0].schema();
let store = LanceIndexStore::new(
self.object_store.clone(),
pk_index_path(gen_path),
Arc::new(LanceCache::no_cache()),
);
let stream: SendableRecordBatchStream = Box::pin(RecordBatchStreamAdapter::new(
schema,
futures::stream::iter(batches.into_iter().map(Ok)),
));
train_btree_index(stream, &store, TRAINING_BATCH_SIZE as u64, None, None).await?;
Ok(())
}
async fn already_published(
&self,
memtable: &MemTable,
covered_wal_entry_position: u64,
) -> Result<Option<FlushResult>> {
let generation = memtable.generation();
let manifest = self.manifest_store.latest().await?.ok_or_else(|| {
Error::io(format!(
"Shard manifest not found for shard {}",
self.shard_id
))
})?;
match manifest.current_generation.cmp(&generation) {
std::cmp::Ordering::Equal => Ok(None),
std::cmp::Ordering::Less => Err(Error::internal(format!(
"MemTable generation {generation} cannot flush before generation {} of shard {}, \
which has not flushed",
manifest.current_generation, self.shard_id
))),
std::cmp::Ordering::Greater => {
let sstable = manifest
.sstables
.into_iter()
.find(|sstable| sstable.generation == generation)
.unwrap_or_else(|| {
FlushedSize::of(memtable).sstable(generation, String::new())
});
Ok(Some(FlushResult {
sstable,
rows_flushed: memtable.row_count(),
covered_wal_entry_position,
}))
}
}
}
async fn update_manifest(
&self,
epoch: u64,
generation: u64,
gen_path: &str,
covered_wal_entry_position: u64,
size: FlushedSize,
) -> Result<ShardManifest> {
let gen_path = gen_path.to_string();
self.manifest_store
.commit_update(epoch, |current| {
let mut sstables = current.sstables.clone();
sstables.push(size.sstable(generation, gen_path.clone()));
ShardManifest {
version: current.next_version(),
replay_after_wal_entry_position: covered_wal_entry_position,
wal_entry_position_last_seen: current
.wal_entry_position_last_seen
.max(covered_wal_entry_position),
current_generation: generation + 1,
sstables,
..current.clone()
}
})
.await
}
}
pub enum TriggerMemTableFlush {
Flush {
memtable: Arc<MemTable>,
index_specs: Arc<[MemIndexSpec]>,
done: Option<tokio::sync::oneshot::Sender<Result<FlushResult>>>,
},
}
impl std::fmt::Debug for TriggerMemTableFlush {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Flush {
memtable,
index_specs,
done,
} => f
.debug_struct("TriggerMemTableFlush::Flush")
.field("memtable_gen", &memtable.generation())
.field("memtable_rows", &memtable.row_count())
.field("index_count", &index_specs.len())
.field("has_done", &done.is_some())
.finish(),
}
}
}
async fn build_scalar_index(
dataset: &mut Dataset,
spec: &MemIndexSpec,
training_data: Option<SendableRecordBatchStream>,
) -> Result<IndexMetadata> {
let index_type = spec.plugin.flush_index_type();
if !index_type.is_scalar() {
return Err(Error::invalid_input(format!(
"index '{}' asked the flush to build a {index_type} index, but the flush builds \
only scalar types; plugin '{}' must write it and return FlushOutcome::Wrote",
spec.name,
spec.plugin.name()
)));
}
let columns: Vec<&str> = spec.columns.iter().map(String::as_str).collect();
let params = spec.plugin.flush_params(spec)?;
let mut builder =
CreateIndexBuilder::new(dataset, &columns, index_type, ¶ms).name(spec.name.clone());
if let Some(stream) = training_data {
builder = builder.preprocessed_stream(stream, spec.plugin.training_criteria());
}
builder.execute_uncommitted().await
}
#[cfg(test)]
mod tests {
use super::*;
use crate::dataset::mem_wal::index::test_plugin::{Deviation, wrapped};
use arrow_array::{Int32Array, RecordBatch, StringArray};
use arrow_schema::{DataType, Field, Schema as ArrowSchema};
use lance_index::scalar::inverted::INVERTED_INDEX_VERSION_V2;
use std::sync::Arc;
use tempfile::TempDir;
async fn create_local_store() -> (Arc<ObjectStore>, Path, String, TempDir) {
let temp_dir = tempfile::tempdir().unwrap();
let uri = format!("file://{}", temp_dir.path().display());
let (store, path) = ObjectStore::from_uri(&uri).await.unwrap();
(store, path, uri, temp_dir)
}
struct FlushFixture {
manifest_store: Arc<ShardManifestStore>,
flusher: MemTableFlusher,
epoch: u64,
_temp_dir: TempDir,
}
impl FlushFixture {
async fn new() -> Self {
let (store, base_path, base_uri, temp_dir) = create_local_store().await;
let shard_id = Uuid::new_v4();
let manifest_store = Arc::new(ShardManifestStore::new(
store.clone(),
&base_path,
shard_id,
2,
));
let (epoch, _) = manifest_store.claim_epoch(0).await.unwrap();
let flusher =
MemTableFlusher::new(store, base_path, base_uri, shard_id, manifest_store.clone());
Self {
manifest_store,
flusher,
epoch,
_temp_dir: temp_dir,
}
}
async fn flush_and_read_back(&self, memtable: &MemTable, durable: usize) -> SsTable {
let result = self
.flusher
.flush(memtable, self.epoch, 1, durable)
.await
.unwrap();
let version = self.manifest_store.latest().await.unwrap().unwrap().version;
self.manifest_store
.read_version(version)
.await
.unwrap()
.sstables
.into_iter()
.find(|sstable| sstable.generation == result.sstable.generation)
.expect("the flush recorded its generation")
}
}
fn test_lance_schema() -> lance_core::datatypes::Schema {
lance_core::datatypes::Schema::try_from(create_test_schema().as_ref()).unwrap()
}
fn create_test_schema() -> Arc<ArrowSchema> {
Arc::new(ArrowSchema::new(vec![
Field::new("id", DataType::Int32, false),
Field::new("name", DataType::Utf8, true),
]))
}
fn create_pk_schema() -> Arc<ArrowSchema> {
let mut id_metadata = std::collections::HashMap::new();
id_metadata.insert(
"lance-schema:unenforced-primary-key".to_string(),
"true".to_string(),
);
let id_field = Field::new("id", DataType::Int32, false).with_metadata(id_metadata);
Arc::new(ArrowSchema::new(vec![
id_field,
Field::new("name", DataType::Utf8, true),
]))
}
fn create_test_batch(schema: &ArrowSchema, num_rows: usize) -> RecordBatch {
RecordBatch::try_new(
Arc::new(schema.clone()),
vec![
Arc::new(Int32Array::from_iter_values(0..num_rows as i32)),
Arc::new(StringArray::from_iter_values(
(0..num_rows).map(|i| format!("name_{}", i)),
)),
],
)
.unwrap()
}
#[tokio::test]
async fn test_flusher_requires_wal_flush() {
let (store, base_path, base_uri, _temp_dir) = create_local_store().await;
let shard_id = Uuid::new_v4();
let manifest_store = Arc::new(ShardManifestStore::new(
store.clone(),
&base_path,
shard_id,
2,
));
let (epoch, _manifest) = manifest_store.claim_epoch(0).await.unwrap();
let schema = create_test_schema();
let mut memtable = MemTable::new(schema.clone(), 1, vec![]).unwrap();
memtable
.insert(create_test_batch(&schema, 10))
.await
.unwrap();
let durable = 0;
assert!(!memtable.all_flushed_to_wal(durable));
let flusher = MemTableFlusher::new(store, base_path, base_uri, shard_id, manifest_store);
let result = flusher.flush(&memtable, epoch, 0, 0).await;
assert!(result.is_err());
assert!(
result
.unwrap_err()
.to_string()
.contains("unflushed fragments")
);
}
#[tokio::test]
async fn test_flusher_empty_memtable() {
let (store, base_path, base_uri, _temp_dir) = create_local_store().await;
let shard_id = Uuid::new_v4();
let manifest_store = Arc::new(ShardManifestStore::new(
store.clone(),
&base_path,
shard_id,
2,
));
let (epoch, _manifest) = manifest_store.claim_epoch(0).await.unwrap();
let schema = create_test_schema();
let memtable = MemTable::new(schema, 1, vec![]).unwrap();
let flusher = MemTableFlusher::new(store, base_path, base_uri, shard_id, manifest_store);
let result = flusher.flush(&memtable, epoch, 0, 0).await;
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("empty MemTable"));
}
#[tokio::test]
async fn test_flusher_success() {
let (store, base_path, base_uri, _temp_dir) = create_local_store().await;
let shard_id = Uuid::new_v4();
let manifest_store = Arc::new(ShardManifestStore::new(
store.clone(),
&base_path,
shard_id,
2,
));
let (epoch, _manifest) = manifest_store.claim_epoch(0).await.unwrap();
let schema = create_test_schema();
let mut memtable = MemTable::new(schema.clone(), 1, vec![]).unwrap();
let frag_id = memtable
.insert(create_test_batch(&schema, 10))
.await
.unwrap();
let durable = frag_id + 1;
assert!(memtable.all_flushed_to_wal(durable));
let flusher = MemTableFlusher::new(
store.clone(),
base_path,
base_uri,
shard_id,
manifest_store.clone(),
);
let result = flusher.flush(&memtable, epoch, 1, durable).await.unwrap();
assert_eq!(result.sstable.generation, 1);
assert_eq!(result.rows_flushed, 10);
assert_eq!(result.covered_wal_entry_position, 1);
let updated_manifest = manifest_store.latest().await.unwrap().unwrap();
assert_eq!(updated_manifest.version, 2);
assert_eq!(updated_manifest.replay_after_wal_entry_position, 1);
assert_eq!(updated_manifest.current_generation, 2);
assert_eq!(updated_manifest.sstables.len(), 1);
}
#[tokio::test]
async fn a_flush_publishes_only_the_next_generation() {
let (store, base_path, base_uri, _temp_dir) = create_local_store().await;
let shard_id = Uuid::new_v4();
let manifest_store = Arc::new(ShardManifestStore::new(
store.clone(),
&base_path,
shard_id,
2,
));
let (epoch, _manifest) = manifest_store.claim_epoch(0).await.unwrap();
let flusher = MemTableFlusher::new(
store.clone(),
base_path,
base_uri,
shard_id,
manifest_store.clone(),
);
let schema = create_test_schema();
let mut first = MemTable::new(schema.clone(), 1, vec![]).unwrap();
let durable = first.insert(create_test_batch(&schema, 10)).await.unwrap() + 1;
let mut second = MemTable::new(schema.clone(), 2, vec![]).unwrap();
second.insert(create_test_batch(&schema, 10)).await.unwrap();
let refused = flusher.flush(&second, epoch, 2, durable).await.unwrap_err();
assert!(
refused
.to_string()
.contains("cannot flush before generation 1"),
"{refused}"
);
let untouched = manifest_store.latest().await.unwrap().unwrap();
assert_eq!(untouched.current_generation, 1);
assert!(untouched.sstables.is_empty());
flusher.flush(&first, epoch, 1, durable).await.unwrap();
let published = manifest_store.latest().await.unwrap().unwrap();
let again = flusher.flush(&first, epoch, 1, durable).await.unwrap();
assert_eq!(again.sstable, published.sstables[0]);
assert_eq!(manifest_store.latest().await.unwrap().unwrap(), published);
}
#[tokio::test]
async fn flushed_sstable_records_what_it_holds() {
let fixture = FlushFixture::new().await;
let schema = create_test_schema();
let mut memtable = MemTable::new(schema.clone(), 1, vec![]).unwrap();
let rows = 10;
let frag_id = memtable
.insert(create_test_batch(&schema, rows))
.await
.unwrap();
let accounted = memtable.batch_store().row_bytes() as u64;
let entry = fixture.flush_and_read_back(&memtable, frag_id + 1).await;
assert_eq!(entry.physical_rows, Some(rows as u64));
assert_eq!(entry.in_memory_bytes, Some(accounted));
let payload = rows as u64 * std::mem::size_of::<i32>() as u64;
assert!(
entry.in_memory_bytes.unwrap() > payload,
"recorded {:?} should exceed the raw payload {payload}",
entry.in_memory_bytes
);
assert_eq!(entry.primary_key_bytes, None);
}
#[tokio::test]
async fn flushed_sstable_records_its_primary_key_size() {
let fixture = FlushFixture::new().await;
let schema = create_pk_schema();
let mut memtable = MemTable::new(schema.clone(), 1, vec![0]).unwrap();
let rows = 10;
let frag_id = memtable
.insert(create_test_batch(&schema, rows))
.await
.unwrap();
let entry = fixture.flush_and_read_back(&memtable, frag_id + 1).await;
let key_bytes = entry.primary_key_bytes.expect("a keyed table records it");
assert!(
key_bytes >= rows as u64 * std::mem::size_of::<i32>() as u64,
"recorded {key_bytes} is below the raw key payload"
);
assert!(
key_bytes < entry.in_memory_bytes.unwrap(),
"keys ({key_bytes}) cannot outweigh the whole MemTable ({:?})",
entry.in_memory_bytes
);
}
#[derive(Debug)]
struct CountingWarmer {
calls: Arc<std::sync::atomic::AtomicUsize>,
fail: bool,
}
#[async_trait::async_trait]
impl SsTableWarmer for CountingWarmer {
async fn warm(&self, _path: &str) -> Result<()> {
self.calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
if self.fail {
Err(Error::io("simulated warm failure".to_string()))
} else {
Ok(())
}
}
}
#[tokio::test]
async fn test_flusher_commits_when_warm_fails() {
let (store, base_path, base_uri, _temp_dir) = create_local_store().await;
let shard_id = Uuid::new_v4();
let manifest_store = Arc::new(ShardManifestStore::new(
store.clone(),
&base_path,
shard_id,
2,
));
let (epoch, _manifest) = manifest_store.claim_epoch(0).await.unwrap();
let schema = create_test_schema();
let mut memtable = MemTable::new(schema.clone(), 1, vec![]).unwrap();
let frag_id = memtable
.insert(create_test_batch(&schema, 10))
.await
.unwrap();
let durable = frag_id + 1;
let calls = Arc::new(std::sync::atomic::AtomicUsize::new(0));
let warmer: Arc<dyn SsTableWarmer> = Arc::new(CountingWarmer {
calls: calls.clone(),
fail: true,
});
let flusher = MemTableFlusher::new(
store.clone(),
base_path,
base_uri,
shard_id,
manifest_store.clone(),
)
.with_warmer(Some(warmer));
let result = flusher.flush(&memtable, epoch, 1, durable).await.unwrap();
assert_eq!(result.sstable.generation, 1);
assert_eq!(
calls.load(std::sync::atomic::Ordering::SeqCst),
1,
"pre-commit warm fires exactly once"
);
let updated = manifest_store.latest().await.unwrap().unwrap();
assert_eq!(
updated.sstables.len(),
1,
"generation still committed after a failed warm"
);
}
#[tokio::test]
async fn test_flush_writes_dedup_deletion_vector() {
use futures::TryStreamExt;
let (store, base_path, base_uri, _temp_dir) = create_local_store().await;
let shard_id = Uuid::new_v4();
let manifest_store = Arc::new(ShardManifestStore::new(
store.clone(),
&base_path,
shard_id,
2,
));
let (epoch, _manifest) = manifest_store.claim_epoch(0).await.unwrap();
let schema = create_pk_schema();
let mut memtable = MemTable::new(schema.clone(), 1, vec![0]).unwrap();
let batch = RecordBatch::try_new(
schema.clone(),
vec![
Arc::new(Int32Array::from(vec![1, 2, 3, 1, 3])),
Arc::new(StringArray::from(vec!["a", "b", "c", "a2", "c2"])),
],
)
.unwrap();
let frag_id = memtable.insert(batch).await.unwrap();
let durable = frag_id + 1;
let flusher = MemTableFlusher::new(
store.clone(),
base_path,
base_uri.clone(),
shard_id,
manifest_store,
);
let result = flusher.flush(&memtable, epoch, 1, durable).await.unwrap();
assert_eq!(result.rows_flushed, 5, "all physical rows are written");
let gen_uri = format!(
"{}/_mem_wal/{}/{}",
base_uri.trim_end_matches('/'),
shard_id,
result.sstable.path
);
let dataset = Dataset::open(&gen_uri).await.unwrap();
let batches: Vec<RecordBatch> = dataset
.scan()
.try_into_stream()
.await
.unwrap()
.try_collect()
.await
.unwrap();
let mut rows = std::collections::HashMap::new();
for b in &batches {
let ids = b
.column_by_name("id")
.unwrap()
.as_any()
.downcast_ref::<Int32Array>()
.unwrap();
let names = b
.column_by_name("name")
.unwrap()
.as_any()
.downcast_ref::<StringArray>()
.unwrap();
for i in 0..b.num_rows() {
rows.insert(ids.value(i), names.value(i).to_string());
}
}
assert_eq!(
rows.len(),
3,
"deletion vector should leave newest-per-PK, got {:?}",
rows
);
assert_eq!(rows.get(&1), Some(&"a2".to_string()));
assert_eq!(rows.get(&2), Some(&"b".to_string()));
assert_eq!(rows.get(&3), Some(&"c2".to_string()));
}
#[tokio::test]
async fn sstable_pk_index_sidecar_is_probeable() {
use lance_core::cache::LanceCache;
use lance_index::metrics::NoOpMetricsCollector;
use lance_index::registry::IndexPluginRegistry;
use lance_index::scalar::lance_format::LanceIndexStore;
use lance_index::scalar::{SargableQuery, SearchResult};
use super::super::super::index::IndexStore;
use crate::dataset::mem_wal::util::pk_index_path;
use datafusion::common::ScalarValue;
let (store, base_path, _base_uri, _temp_dir) = create_local_store().await;
let shard_id = Uuid::new_v4();
let manifest_store = Arc::new(ShardManifestStore::new(
store.clone(),
&base_path,
shard_id,
2,
));
let (epoch, _manifest) = manifest_store.claim_epoch(0).await.unwrap();
let schema = create_pk_schema();
let mut memtable = MemTable::new(schema.clone(), 1, vec![0]).unwrap();
let mut registry = IndexStore::new();
registry.enable_pk_index(&[("id".to_string(), 0)]);
memtable.set_indexes(registry);
let batch = RecordBatch::try_new(
schema.clone(),
vec![
Arc::new(Int32Array::from(vec![1, 2, 1])),
Arc::new(StringArray::from(vec!["a", "b", "a2"])),
],
)
.unwrap();
let frag_id = memtable.insert(batch).await.unwrap();
let durable = frag_id + 1;
let flusher = MemTableFlusher::new(
store.clone(),
base_path.clone(),
_base_uri.clone(),
shard_id,
manifest_store.clone(),
);
let result = flusher
.flush_with_indexes(&memtable, epoch, &[], 1, durable)
.await
.unwrap();
let gen_path = base_path
.clone()
.join("_mem_wal")
.join(shard_id.to_string())
.join(result.sstable.path.as_str());
let index_store = Arc::new(LanceIndexStore::new(
store.clone(),
pk_index_path(&gen_path),
Arc::new(LanceCache::no_cache()),
));
let registry = IndexPluginRegistry::with_default_plugins();
let plugin = registry.get_plugin_by_name("BTree").unwrap();
let details =
prost_types::Any::from_msg(&lance_index::pbold::BTreeIndexDetails::default()).unwrap();
let index = plugin
.load_index(
index_store,
&details,
lance_index::scalar::btree::BTREE_INDEX_VERSION,
None,
&LanceCache::no_cache(),
)
.await
.unwrap();
let contains = |id: i32| {
let index = index.clone();
async move {
let result = index
.search(
&SargableQuery::Equals(ScalarValue::Int32(Some(id))),
&NoOpMetricsCollector,
)
.await
.unwrap();
match result {
SearchResult::Exact(s) | SearchResult::AtMost(s) | SearchResult::AtLeast(s) => {
!s.is_empty()
}
}
}
};
assert!(contains(1).await);
assert!(contains(2).await);
assert!(!contains(99).await);
}
#[tokio::test]
async fn plain_flush_writes_pk_sidecar() {
use lance_core::cache::LanceCache;
use lance_index::metrics::NoOpMetricsCollector;
use lance_index::registry::IndexPluginRegistry;
use lance_index::scalar::lance_format::LanceIndexStore;
use lance_index::scalar::{SargableQuery, SearchResult};
use super::super::super::index::IndexStore;
use crate::dataset::mem_wal::util::pk_index_path;
use datafusion::common::ScalarValue;
let (store, base_path, _base_uri, _temp_dir) = create_local_store().await;
let shard_id = Uuid::new_v4();
let manifest_store = Arc::new(ShardManifestStore::new(
store.clone(),
&base_path,
shard_id,
2,
));
let (epoch, _manifest) = manifest_store.claim_epoch(0).await.unwrap();
let schema = create_pk_schema();
let mut memtable = MemTable::new(schema.clone(), 1, vec![0]).unwrap();
let mut registry = IndexStore::new();
registry.enable_pk_index(&[("id".to_string(), 0)]);
memtable.set_indexes(registry);
let batch = RecordBatch::try_new(
schema.clone(),
vec![
Arc::new(Int32Array::from(vec![1, 2])),
Arc::new(StringArray::from(vec!["a", "b"])),
],
)
.unwrap();
let frag_id = memtable.insert(batch).await.unwrap();
let durable = frag_id + 1;
let flusher = MemTableFlusher::new(
store.clone(),
base_path.clone(),
_base_uri.clone(),
shard_id,
manifest_store.clone(),
);
let result = flusher.flush(&memtable, epoch, 1, durable).await.unwrap();
let gen_path = base_path
.clone()
.join("_mem_wal")
.join(shard_id.to_string())
.join(result.sstable.path.as_str());
let index_store = Arc::new(LanceIndexStore::new(
store.clone(),
pk_index_path(&gen_path),
Arc::new(LanceCache::no_cache()),
));
let registry = IndexPluginRegistry::with_default_plugins();
let plugin = registry.get_plugin_by_name("BTree").unwrap();
let details =
prost_types::Any::from_msg(&lance_index::pbold::BTreeIndexDetails::default()).unwrap();
let index = plugin
.load_index(
index_store,
&details,
lance_index::scalar::btree::BTREE_INDEX_VERSION,
None,
&LanceCache::no_cache(),
)
.await
.unwrap();
let contains = |id: i32| {
let index = index.clone();
async move {
let result = index
.search(
&SargableQuery::Equals(ScalarValue::Int32(Some(id))),
&NoOpMetricsCollector,
)
.await
.unwrap();
match result {
SearchResult::Exact(s) | SearchResult::AtMost(s) | SearchResult::AtLeast(s) => {
!s.is_empty()
}
}
}
};
assert!(contains(1).await);
assert!(contains(2).await);
assert!(!contains(99).await);
}
#[tokio::test]
async fn test_flush_with_indexes_and_dedup_deletion_vector() {
use super::super::super::index::IndexStore;
use crate::index::DatasetIndexExt;
use futures::TryStreamExt;
let (store, base_path, base_uri, _temp_dir) = create_local_store().await;
let shard_id = Uuid::new_v4();
let manifest_store = Arc::new(ShardManifestStore::new(
store.clone(),
&base_path,
shard_id,
2,
));
let (epoch, _manifest) = manifest_store.claim_epoch(0).await.unwrap();
let index_specs = vec![MemIndexSpec::btree("name_btree", 1, "name")];
let schema = create_pk_schema();
let mut memtable = MemTable::new(schema.clone(), 1, vec![0]).unwrap();
let registry =
IndexStore::from_specs(&index_specs, &test_lance_schema(), 100_000, 1_000).unwrap();
memtable.set_indexes(registry);
let batch = RecordBatch::try_new(
schema.clone(),
vec![
Arc::new(Int32Array::from(vec![1, 2, 3, 1, 3])),
Arc::new(StringArray::from(vec!["a", "b", "c", "a2", "c2"])),
],
)
.unwrap();
let frag_id = memtable.insert(batch).await.unwrap();
let durable = frag_id + 1;
let flusher = MemTableFlusher::new(
store.clone(),
base_path.clone(),
base_uri.clone(),
shard_id,
manifest_store.clone(),
);
let result = flusher
.flush_with_indexes(&memtable, epoch, &index_specs, 1, durable)
.await
.unwrap();
assert_eq!(result.rows_flushed, 5, "all physical rows are written");
let gen_uri = format!(
"{}/_mem_wal/{}/{}",
base_uri.trim_end_matches('/'),
shard_id,
result.sstable.path
);
let dataset = Dataset::open(&gen_uri).await.unwrap();
assert_eq!(
dataset.version().version,
1,
"SSTable dataset must be a single-version dataset"
);
let indices = dataset.load_indices().await.unwrap();
assert_eq!(indices.len(), 1);
assert_eq!(indices[0].name, "name_btree");
let batches: Vec<RecordBatch> = dataset
.scan()
.try_into_stream()
.await
.unwrap()
.try_collect()
.await
.unwrap();
let mut rows = std::collections::HashMap::new();
for b in &batches {
let ids = b
.column_by_name("id")
.unwrap()
.as_any()
.downcast_ref::<Int32Array>()
.unwrap();
let names = b
.column_by_name("name")
.unwrap()
.as_any()
.downcast_ref::<StringArray>()
.unwrap();
for i in 0..b.num_rows() {
rows.insert(ids.value(i), names.value(i).to_string());
}
}
assert_eq!(
rows.len(),
3,
"deletion vector should leave newest-per-PK, got {:?}",
rows
);
assert_eq!(rows.get(&1), Some(&"a2".to_string()));
assert_eq!(rows.get(&2), Some(&"b".to_string()));
assert_eq!(rows.get(&3), Some(&"c2".to_string()));
let stale_hits = dataset
.scan()
.filter("name = 'a'")
.unwrap()
.try_into_batch()
.await
.unwrap();
assert_eq!(
stale_hits.num_rows(),
0,
"older name 'a' for id=1 must be filtered out by the deletion vector"
);
let fresh_hits = dataset
.scan()
.filter("name = 'a2'")
.unwrap()
.try_into_batch()
.await
.unwrap();
assert_eq!(fresh_hits.num_rows(), 1);
}
#[tokio::test]
async fn test_flusher_with_btree_index() {
use super::super::super::index::IndexStore;
use crate::index::DatasetIndexExt;
let (store, base_path, base_uri, _temp_dir) = create_local_store().await;
let shard_id = Uuid::new_v4();
let manifest_store = Arc::new(ShardManifestStore::new(
store.clone(),
&base_path,
shard_id,
2,
));
let (epoch, _manifest) = manifest_store.claim_epoch(0).await.unwrap();
let index_specs = vec![MemIndexSpec::btree("id_btree", 0, "id")];
let schema = create_test_schema();
let mut memtable = MemTable::new(schema.clone(), 1, vec![]).unwrap();
let registry =
IndexStore::from_specs(&index_specs, &test_lance_schema(), 100_000, 1_000).unwrap();
memtable.set_indexes(registry);
let frag_id = memtable
.insert(create_test_batch(&schema, 10))
.await
.unwrap();
let durable = frag_id + 1;
let flusher = MemTableFlusher::new(
store.clone(),
base_path.clone(),
base_uri.clone(),
shard_id,
manifest_store.clone(),
);
let result = flusher
.flush_with_indexes(&memtable, epoch, &index_specs, 1, durable)
.await
.unwrap();
assert_eq!(result.sstable.generation, 1);
assert_eq!(result.rows_flushed, 10);
let gen_uri = format!("{}/_mem_wal/{}/{}", base_uri, shard_id, result.sstable.path);
let dataset = Dataset::open(&gen_uri).await.unwrap();
assert_eq!(
dataset.version().version,
1,
"SSTable dataset must be a single-version dataset"
);
let indices = dataset.load_indices().await.unwrap();
assert_eq!(indices.len(), 1);
assert_eq!(indices[0].name, "id_btree");
let batch = dataset
.scan()
.filter("id = 5")
.unwrap()
.try_into_batch()
.await
.unwrap();
assert_eq!(batch.num_rows(), 1);
let id_col = batch
.column_by_name("id")
.unwrap()
.as_any()
.downcast_ref::<arrow_array::Int32Array>()
.unwrap();
assert_eq!(id_col.value(0), 5);
let mut scan = dataset.scan();
scan.filter("id = 5").unwrap();
scan.prefilter(true);
let plan = scan.create_plan().await.unwrap();
crate::utils::test::assert_plan_node_equals(
plan,
"LanceRead: ...full_filter=id = Int32(5)...
ScalarIndexQuery: query=[id = 5]@id_btree(BTree)",
)
.await
.unwrap();
}
#[tokio::test]
async fn test_flusher_with_hnsw_index() {
use super::super::super::index::IndexStore;
use crate::index::DatasetIndexExt;
use arrow_array::{FixedSizeListArray, Float32Array};
use lance_linalg::distance::DistanceType;
let (store, base_path, base_uri, _temp_dir) = create_local_store().await;
let shard_id = Uuid::new_v4();
let manifest_store = Arc::new(ShardManifestStore::new(
store.clone(),
&base_path,
shard_id,
2,
));
let (epoch, _manifest) = manifest_store.claim_epoch(0).await.unwrap();
let vector_dim = 8;
let num_vectors = 300;
let vector_schema = Arc::new(ArrowSchema::new(vec![
Field::new("id", DataType::Int32, false),
Field::new(
"vector",
DataType::FixedSizeList(
Arc::new(Field::new("item", DataType::Float32, false)),
vector_dim as i32,
),
false,
),
]));
let vectors: Vec<f32> = (0..num_vectors * vector_dim)
.map(|i| ((i as f32 * 0.1).sin() + (i as f32 * 0.05).cos()) * 0.5)
.collect();
let vectors_array = Float32Array::from(vectors);
let index_specs = vec![MemIndexSpec::hnsw(
"vector_hnsw",
1,
"vector",
DistanceType::L2,
)];
let mut memtable = MemTable::new(vector_schema.clone(), 1, vec![]).unwrap();
let registry =
IndexStore::from_specs(&index_specs, &test_lance_schema(), num_vectors, 100).unwrap();
memtable.set_indexes(registry);
let ids = Int32Array::from_iter_values(0..num_vectors as i32);
let inner_field = Arc::new(Field::new("item", DataType::Float32, false));
let vectors_fsl_data = FixedSizeListArray::try_new(
inner_field,
vector_dim as i32,
Arc::new(vectors_array),
None,
)
.unwrap();
let batch = RecordBatch::try_new(
vector_schema.clone(),
vec![Arc::new(ids), Arc::new(vectors_fsl_data)],
)
.unwrap();
let frag_id = memtable.insert(batch).await.unwrap();
let durable = frag_id + 1;
let flusher = MemTableFlusher::new(
store.clone(),
base_path.clone(),
base_uri.clone(),
shard_id,
manifest_store.clone(),
);
let result = flusher
.flush_with_indexes(&memtable, epoch, &index_specs, 1, durable)
.await
.unwrap();
assert_eq!(result.sstable.generation, 1);
assert_eq!(result.rows_flushed, num_vectors);
let gen_uri = format!("{}/_mem_wal/{}/{}", base_uri, shard_id, result.sstable.path);
let dataset = Dataset::open(&gen_uri).await.unwrap();
assert_eq!(
dataset.version().version,
1,
"SSTable dataset must be a single-version dataset"
);
let indices = dataset.load_indices().await.unwrap();
assert_eq!(indices.len(), 1);
assert_eq!(indices[0].name, "vector_hnsw");
let scanned: Vec<RecordBatch> = {
use futures::TryStreamExt;
dataset
.scan()
.try_into_stream()
.await
.unwrap()
.try_collect()
.await
.unwrap()
};
let total_scanned: usize = scanned.iter().map(|b| b.num_rows()).sum();
assert_eq!(total_scanned, num_vectors);
let first_vec_values: Vec<f32> = (0..vector_dim)
.map(|i| ((i as f32 * 0.1).sin() + (i as f32 * 0.05).cos()) * 0.5)
.collect();
let query = Float32Array::from(first_vec_values);
let mut scan = dataset.scan();
scan.nearest("vector", &query, 5).unwrap();
scan.fast_search();
let batch = scan.try_into_batch().await.unwrap();
assert!(batch.num_rows() > 0, "query returned no rows");
let dist_col = batch
.column_by_name("_distance")
.expect("_distance column missing")
.as_any()
.downcast_ref::<Float32Array>()
.unwrap();
assert!(
dist_col.value(0) < 1e-3,
"expected near-zero distance for self-match, got {}",
dist_col.value(0)
);
let mut scan = dataset.scan();
scan.nearest("vector", &query, 5).unwrap();
scan.fast_search();
let plan = scan.create_plan().await.unwrap();
let plan_str = format!(
"{}",
datafusion::physical_plan::displayable(plan.as_ref()).indent(true)
);
assert!(
plan_str.contains("ANNSubIndex: name=vector_hnsw, k=5"),
"query plan must use HNSW index, got: {plan_str}"
);
assert!(
plan_str.contains("ANNIvfPartition:"),
"query plan must use IVF partition, got: {plan_str}"
);
}
#[tokio::test]
async fn test_flusher_cosine_index_ranks_unnormalized_vectors() {
use super::super::super::index::IndexStore;
use arrow_array::{FixedSizeListArray, Float32Array};
use lance_linalg::distance::DistanceType;
let (store, base_path, base_uri, _temp_dir) = create_local_store().await;
let shard_id = Uuid::new_v4();
let manifest_store = Arc::new(ShardManifestStore::new(
store.clone(),
&base_path,
shard_id,
2,
));
let (epoch, _manifest) = manifest_store.claim_epoch(0).await.unwrap();
let (dim, num_vectors) = (32usize, 2000usize);
let mut seed: u64 = 42;
let mut next = || {
seed = seed
.wrapping_mul(6364136223846793005)
.wrapping_add(1442695040888963407);
((seed >> 33) as f32 / (1u64 << 31) as f32) * 2.0 - 1.0
};
let mut vectors = Vec::with_capacity(num_vectors * dim);
for _ in 0..num_vectors {
let row: Vec<f32> = (0..dim).map(|_| next()).collect();
let norm = row.iter().map(|x| x * x).sum::<f32>().sqrt();
let target = 0.2 + 4.8 * (next() * 0.5 + 0.5);
vectors.extend(row.iter().map(|x| x * target / norm));
}
let query: Vec<f32> = (0..dim).map(|_| next()).collect();
let cosine_distance = |id: usize| {
lance_linalg::distance::cosine_distance(&query, &vectors[id * dim..][..dim])
};
let mut by_distance: Vec<usize> = (0..num_vectors).collect();
by_distance.sort_by(|a, b| cosine_distance(*a).total_cmp(&cosine_distance(*b)));
let nearest: std::collections::HashSet<i32> =
by_distance[..10].iter().map(|id| *id as i32).collect();
let item = Arc::new(Field::new("item", DataType::Float32, false));
let schema = Arc::new(ArrowSchema::new(vec![
Field::new("id", DataType::Int32, false),
Field::new(
"vector",
DataType::FixedSizeList(item.clone(), dim as i32),
false,
),
]));
let index_specs = vec![MemIndexSpec::hnsw(
"vector_hnsw",
1,
"vector",
DistanceType::Cosine,
)];
let mut memtable = MemTable::new(schema.clone(), 1, vec![]).unwrap();
let lance_schema = lance_core::datatypes::Schema::try_from(schema.as_ref()).unwrap();
memtable.set_indexes(
IndexStore::from_specs(&index_specs, &lance_schema, num_vectors, 100).unwrap(),
);
let batch = RecordBatch::try_new(
schema,
vec![
Arc::new(Int32Array::from_iter_values(0..num_vectors as i32)),
Arc::new(
FixedSizeListArray::try_new(
item,
dim as i32,
Arc::new(Float32Array::from(vectors.clone())),
None,
)
.unwrap(),
),
],
)
.unwrap();
let frag_id = memtable.insert(batch).await.unwrap();
let flusher = MemTableFlusher::new(
store.clone(),
base_path.clone(),
base_uri.clone(),
shard_id,
manifest_store.clone(),
);
let result = flusher
.flush_with_indexes(&memtable, epoch, &index_specs, 1, frag_id + 1)
.await
.unwrap();
let gen_uri = format!("{}/_mem_wal/{}/{}", base_uri, shard_id, result.sstable.path);
let dataset = Dataset::open(&gen_uri).await.unwrap();
let mut scan = dataset.scan();
scan.nearest("vector", &Float32Array::from(query.clone()), 10)
.unwrap();
scan.distance_metric(DistanceType::Cosine);
scan.ef(500);
scan.fast_search();
let batch = scan.try_into_batch().await.unwrap();
let ids = batch["id"].as_any().downcast_ref::<Int32Array>().unwrap();
let distances = batch["_distance"]
.as_any()
.downcast_ref::<Float32Array>()
.unwrap();
let found = ids
.values()
.iter()
.filter(|id| nearest.contains(id))
.count();
assert!(found >= 9, "recall@10 was {found}/10");
for (id, distance) in ids.values().iter().zip(distances.values()) {
let expected = 2.0 * cosine_distance(*id as usize);
assert!(
(distance - expected).abs() < 0.05,
"id={id} reported {distance}, expected {expected}"
);
}
}
#[tokio::test]
async fn test_flusher_with_fts_index() {
use super::super::super::index::IndexStore;
use crate::index::DatasetIndexExt;
use arrow_array::StringArray;
use arrow_schema::{DataType, Field, Schema as ArrowSchema};
use std::sync::Arc;
let (store, base_path, base_uri, _temp_dir) = create_local_store().await;
let shard_id = Uuid::new_v4();
let manifest_store = Arc::new(ShardManifestStore::new(
store.clone(),
&base_path,
shard_id,
2,
));
let (epoch, _manifest) = manifest_store.claim_epoch(0).await.unwrap();
let schema = Arc::new(ArrowSchema::new(vec![
Field::new("id", DataType::Int32, false),
Field::new("text", DataType::Utf8, true),
]));
let index_specs = vec![MemIndexSpec::fts("text_fts", 1, "text")];
let mut memtable = MemTable::new(schema.clone(), 1, vec![]).unwrap();
let registry =
IndexStore::from_specs(&index_specs, &test_lance_schema(), 100_000, 1_000).unwrap();
memtable.set_indexes(registry);
let batch = RecordBatch::try_new(
schema.clone(),
vec![
Arc::new(arrow_array::Int32Array::from(vec![1, 2, 3])),
Arc::new(StringArray::from(vec![
"hello world",
"quick brown fox",
"lazy dog jumps",
])),
],
)
.unwrap();
let frag_id = memtable.insert(batch).await.unwrap();
let durable = frag_id + 1;
let flusher = MemTableFlusher::new(
store.clone(),
base_path.clone(),
base_uri.clone(),
shard_id,
manifest_store.clone(),
);
let result = flusher
.flush_with_indexes(&memtable, epoch, &index_specs, 1, durable)
.await
.unwrap();
assert_eq!(result.sstable.generation, 1);
assert_eq!(result.rows_flushed, 3);
let gen_uri = format!("{}/_mem_wal/{}/{}", base_uri, shard_id, result.sstable.path);
let dataset = Dataset::open(&gen_uri).await.unwrap();
assert_eq!(
dataset.version().version,
1,
"SSTable dataset must be a single-version dataset"
);
let indices = dataset.load_indices().await.unwrap();
assert_eq!(indices.len(), 1);
assert_eq!(indices[0].name, "text_fts");
assert_eq!(indices[0].index_version, INVERTED_INDEX_VERSION_V2 as i32);
use lance_index::scalar::FullTextSearchQuery;
let batch = dataset
.scan()
.full_text_search(FullTextSearchQuery::new("hello".to_owned()))
.unwrap()
.try_into_batch()
.await
.unwrap();
assert_eq!(batch.num_rows(), 1);
let id_col = batch
.column_by_name("id")
.unwrap()
.as_any()
.downcast_ref::<arrow_array::Int32Array>()
.unwrap();
assert_eq!(
id_col.value(0),
1,
"Should find document with 'hello world'"
);
let batch = dataset
.scan()
.full_text_search(FullTextSearchQuery::new("fox".to_owned()))
.unwrap()
.try_into_batch()
.await
.unwrap();
assert_eq!(batch.num_rows(), 1);
let id_col = batch
.column_by_name("id")
.unwrap()
.as_any()
.downcast_ref::<arrow_array::Int32Array>()
.unwrap();
assert_eq!(
id_col.value(0),
2,
"Should find document with 'quick brown fox'"
);
let mut scan = dataset.scan();
scan.full_text_search(FullTextSearchQuery::new("hello".to_owned()))
.unwrap();
let plan = scan.create_plan().await.unwrap();
crate::utils::test::assert_plan_node_equals(
plan,
"ProjectionExec: expr=[id@2 as id, text@3 as text, _score@1 as _score]
LanceRead: ..., source=stream(_rowid)
MatchQuery: column=text, query=[hello]",
)
.await
.unwrap();
}
async fn flush_one(spec: MemIndexSpec, batch: RecordBatch) -> Result<Vec<IndexMetadata>> {
use crate::index::DatasetIndexExt;
let (generation, _dir) = flush_generation(spec, batch).await?;
Ok(generation.load_indices().await?.as_ref().clone())
}
async fn flush_generation(
spec: MemIndexSpec,
batch: RecordBatch,
) -> Result<(Dataset, TempDir)> {
use crate::dataset::mem_wal::index::IndexStore;
let (store, base_path, base_uri, temp_dir) = create_local_store().await;
let shard_id = Uuid::new_v4();
let manifest_store = Arc::new(ShardManifestStore::new(
store.clone(),
&base_path,
shard_id,
2,
));
let (epoch, _manifest) = manifest_store.claim_epoch(0).await.unwrap();
let specs = vec![spec];
let lance_schema =
lance_core::datatypes::Schema::try_from(batch.schema().as_ref()).unwrap();
let mut memtable = MemTable::new(batch.schema(), 1, vec![]).unwrap();
memtable.set_indexes(IndexStore::from_specs(&specs, &lance_schema, 1000, 16).unwrap());
let durable = memtable.insert(batch).await.unwrap() + 1;
let result =
MemTableFlusher::new(store, base_path, base_uri.clone(), shard_id, manifest_store)
.flush_with_indexes(&memtable, epoch, &specs, 1, durable)
.await?;
let generation = Dataset::open(&format!(
"{}/_mem_wal/{}/{}",
base_uri.trim_end_matches('/'),
shard_id,
result.sstable.path
))
.await?;
Ok((generation, temp_dir))
}
#[tokio::test]
async fn an_empty_index_is_left_out_of_the_generation() {
use arrow_array::Array;
use arrow_array::builder::{ListBuilder, StringBuilder};
use lance_index::scalar::InvertedIndexParams;
use lance_index::scalar::inverted::DocumentGranularity;
let mut tags = ListBuilder::new(StringBuilder::new());
tags.append(false);
tags.append(true);
let tags = tags.finish();
let schema = Arc::new(ArrowSchema::new(vec![
Field::new("id", DataType::Int32, false),
Field::new("tags", tags.data_type().clone(), true),
]));
let batch = RecordBatch::try_new(
schema,
vec![Arc::new(Int32Array::from(vec![1, 2])), Arc::new(tags)],
)
.unwrap();
let spec = MemIndexSpec::fts_with_params(
"tags_fts",
1,
"tags",
InvertedIndexParams::default().document_granularity(DocumentGranularity::ListElement),
);
assert!(flush_one(spec, batch).await.unwrap().is_empty());
}
#[tokio::test]
async fn a_vector_plugin_asking_the_flush_to_build_it_is_an_error() {
use arrow_array::{FixedSizeListArray, Float32Array};
use lance_linalg::distance::DistanceType;
let item = Arc::new(Field::new("item", DataType::Float32, false));
let schema = Arc::new(ArrowSchema::new(vec![
Field::new("id", DataType::Int32, false),
Field::new("vector", DataType::FixedSizeList(item.clone(), 2), false),
]));
let vectors = FixedSizeListArray::try_new(
item,
2,
Arc::new(Float32Array::from_iter_values((0..20).map(|i| i as f32))),
None,
)
.unwrap();
let batch = RecordBatch::try_new(
schema,
vec![
Arc::new(Int32Array::from((0..10).collect::<Vec<_>>())),
Arc::new(vectors),
],
)
.unwrap();
let spec = wrapped(
MemIndexSpec::hnsw("vector_hnsw", 1, "vector", DistanceType::L2),
Deviation::AsksForABuild,
);
let error = flush_one(spec, batch).await.unwrap_err();
assert!(
error.to_string().contains("vector_hnsw") && error.to_string().contains("Wrote"),
"{error}"
);
}
fn text_batch(texts: &[&str]) -> RecordBatch {
let schema = Arc::new(ArrowSchema::new(vec![
Field::new("id", DataType::Int32, false),
Field::new("text", DataType::Utf8, true),
]));
RecordBatch::try_new(
schema,
vec![
Arc::new(Int32Array::from_iter_values(0..texts.len() as i32)),
Arc::new(StringArray::from(texts.to_vec())),
],
)
.unwrap()
}
#[rstest::rstest]
#[case::fails(Deviation::FailsToFlush, "flush failed")]
#[case::another_name(Deviation::RecordsAnotherName, "another_name")]
#[tokio::test]
async fn an_index_that_does_not_flush_as_itself_fails_the_flush(
#[case] deviation: Deviation,
#[case] message: &str,
) {
let spec = wrapped(MemIndexSpec::fts("text_fts", 1, "text"), deviation);
let error = flush_one(spec, text_batch(&["hello world"]))
.await
.unwrap_err();
assert!(error.to_string().contains(message), "{error}");
}
#[tokio::test]
async fn a_full_text_index_with_positions_answers_phrases_once_flushed() {
use lance_index::scalar::FullTextSearchQuery;
use lance_index::scalar::InvertedIndexParams;
use lance_index::scalar::inverted::query::{FtsQuery, PhraseQuery};
let spec = MemIndexSpec::fts_with_params(
"text_fts",
1,
"text",
InvertedIndexParams::default().with_position(true),
);
let (generation, _dir) =
flush_generation(spec, text_batch(&["quick brown fox", "brown quick"]))
.await
.unwrap();
let found = generation
.scan()
.full_text_search(FullTextSearchQuery::new_query(FtsQuery::Phrase(
PhraseQuery::new("quick brown".to_string()).with_column(Some("text".to_string())),
)))
.unwrap()
.try_into_batch()
.await
.unwrap();
let ids = found
.column_by_name("id")
.unwrap()
.as_any()
.downcast_ref::<Int32Array>()
.unwrap()
.values()
.to_vec();
assert_eq!(ids, vec![0]);
}
#[rstest::rstest]
#[case::training_data(Deviation::None)]
#[case::build_from_generation(Deviation::AsksForABuild)]
#[tokio::test]
async fn a_scalar_build_uses_the_plugin_flush_params(#[case] deviation: Deviation) {
use crate::dataset::mem_wal::index::test_plugin::wrapped_with_flush_params;
use crate::index::{DatasetIndexExt, DatasetIndexInternalExt};
use lance_index::metrics::NoOpMetricsCollector;
use lance_index::scalar::btree::BTreeParameters;
use lance_index::scalar::{BuiltinIndexType, ScalarIndexParams};
const ZONE_SIZE: u64 = 4;
let params = ScalarIndexParams::for_builtin(BuiltinIndexType::BTree)
.with_params(&serde_json::json!({ "zone_size": ZONE_SIZE }));
let spec = wrapped_with_flush_params(
MemIndexSpec::btree("id_btree", 0, "id"),
deviation,
Some(params),
);
let (generation, _dir) =
flush_generation(spec, create_test_batch(&create_test_schema(), 10))
.await
.unwrap();
let indices = generation.load_indices().await.unwrap();
let built = indices
.iter()
.find(|index| index.name == "id_btree")
.expect("the flush built the index");
let derived = generation
.open_scalar_index("id", &built.uuid, &NoOpMetricsCollector)
.await
.unwrap()
.derive_index_params()
.unwrap();
let derived: BTreeParameters =
serde_json::from_str(&derived.params.expect("B-tree parameters")).unwrap();
assert_eq!(derived.zone_size, Some(ZONE_SIZE));
}
}