use std::collections::{HashMap, HashSet};
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Arc, Mutex as StdMutex, OnceLock};
use std::time::{SystemTime, UNIX_EPOCH};
use arrow_array::cast::AsArray;
use arrow_array::types::UInt64Type;
use arrow_array::{RecordBatch, UInt64Array, new_null_array};
use arrow_schema::{DataType, Field as ArrowField, FieldRef, Schema as ArrowSchema, SchemaRef};
use datafusion::common::ScalarValue;
use datafusion::error::DataFusionError;
use datafusion::physical_expr::PhysicalExpr;
use datafusion::physical_plan::SendableRecordBatchStream;
use datafusion::physical_plan::stream::RecordBatchStreamAdapter;
use datafusion::prelude::{col, lit};
use futures::{StreamExt, TryStreamExt};
use lance::Dataset;
use lance::dataset::mem_wal::DatasetMemWalExt;
use lance::dataset::transaction::{Operation, Transaction, UpdateMode};
use lance::dataset::write::delete::DeleteBuilder;
use lance::dataset::write::merge_insert::inserted_rows::{
KeyExistenceFilter, KeyExistenceFilterBuilder, KeyValue,
};
use lance::dataset::{CommitBuilder, InsertBuilder, WriteDestination, WriteMode, WriteParams};
use lance_core::{ROW_CREATED_AT_VERSION, ROW_ID, ROW_LAST_UPDATED_AT_VERSION};
use lance_datafusion::planner::Planner;
use lance_file::version::ConcreteFileVersion;
use lance_table::format::Fragment;
use serde::{Deserialize, Serialize};
use super::{
DEFINITION_META_KEY, INCARNATION_META_KEY, MaterializedViewDefinition,
REFRESHED_AT_MS_META_KEY, SOURCE_ROW_ID_COLUMN, SOURCE_VERSION_META_KEY,
definition_to_metadata,
};
use crate::database::OpenTableRequest;
use crate::table::computed_columns::{
computed_column_from_field, computed_columns, ensure_declarations_are_planned,
};
use crate::table::{NativeTable, NativeTableExt, Table};
use crate::{Error, Result};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum RefreshMode {
Rebuild,
Incremental,
NoOp,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct RefreshMaterializedViewResult {
pub mode: RefreshMode,
pub rows_written: u64,
pub source_version: u64,
pub version: u64,
}
pub const VIEW_VERSION_META_KEY: &str = "mv.view_version";
pub const SOURCE_VERSION_TS_META_KEY: &str = "mv.source_version_ts";
pub(super) fn refresh_lock(uri: &str) -> Arc<tokio::sync::Mutex<()>> {
static LOCKS: OnceLock<StdMutex<HashMap<String, Arc<tokio::sync::Mutex<()>>>>> =
OnceLock::new();
LOCKS
.get_or_init(Default::default)
.lock()
.expect("refresh lock registry poisoned")
.entry(uri.to_string())
.or_default()
.clone()
}
pub(crate) async fn execute_refresh(
view: &Table,
full: bool,
pinned: Option<u64>,
expected_incarnation: Option<&str>,
) -> Result<RefreshMaterializedViewResult> {
if let Some(super::StoredDefinition::Query(definition)) =
super::read_definition(view.schema().await?.metadata())?
&& definition.vector_source.is_some()
{
let plan = super::plan_partitioned_refresh(view, pinned)
.await?
.ok_or_else(|| Error::Runtime {
message: "native pair view has no partition plan".into(),
})?;
let mut units = Vec::with_capacity(plan.units() as usize);
for unit in 0..plan.units() {
let dependencies = &units[..units.len().min(plan.dependency_units() as usize)];
units.push(
super::write_refresh_partition_with_inputs(view, unit, &plan, dependencies).await?,
);
}
return super::commit_partitioned_refresh(view, &plan, units, expected_incarnation).await;
}
let view_native = view.as_native().ok_or_else(|| Error::NotSupported {
message: "materialized views are supported only on local tables".into(),
})?;
view_native.dataset.ensure_mutable()?;
let lock = refresh_lock(view_native.dataset.get().await?.uri());
let _guard = lock.lock().await;
view_native.dataset.reload().await?;
let view_ds = view_native.dataset.get().await?.as_ref().clone();
ensure_incarnation(&view_ds, expected_incarnation, view.name()).await?;
let definition = match super::read_definition(&view_ds.schema().metadata)? {
Some(super::StoredDefinition::Query(definition)) => definition,
Some(super::StoredDefinition::Newer { format }) => {
return Err(Error::NotSupported {
message: format!(
"materialized view '{}' is stored in format {format}, which this \
version of lancedb cannot refresh",
view.name()
),
});
}
None => {
return Err(Error::NotAMaterializedView {
name: view.name().to_string(),
});
}
};
let definition = &definition;
let staging = super::read_staging(&view_ds.schema().metadata)?;
ensure_no_mem_wal(&view_ds, "materialized view", view.name()).await?;
let source_ds = open_source(view, definition, staging.as_ref()).await?;
let source_ds = match pinned {
Some(version) => source_ds.checkout_version(version).await?,
None => source_ds,
};
ensure_no_mem_wal(&source_ds, "source table", &definition.source_table).await?;
let source_version = source_ds.version().version;
let source_ts = source_ds.manifest.timestamp_nanos;
let source_schema = Arc::new(ArrowSchema::from(source_ds.schema()));
let super::Planned {
definition: replanned,
fields: planned_fields,
inputs,
..
} = super::plan(source_schema.clone(), definition, staging.as_ref()).map_err(|e| match e {
Error::InvalidExpression { column, message } => Error::Schema {
message: format!(
"view column '{column}' no longer plans against '{}' (a source column \
was dropped or renamed): {message}",
definition.source_table
),
},
Error::InvalidInput { message } => Error::Schema {
message: format!(
"the stored query no longer plans against '{}' (a source column was \
dropped or renamed): {message}",
definition.source_table
),
},
e => e,
})?;
let mut planned_fields = planned_fields;
planned_fields.push(arrow_schema::Field::new(
SOURCE_ROW_ID_COLUMN,
arrow_schema::DataType::UInt64,
false,
));
let physical = ArrowSchema::from(view_ds.schema());
let mut computed = computed_columns(&physical).into_iter().map(|c| c.name);
if let Some(name) = computed.by_ref().find(|name| {
physical
.field_with_name(name)
.is_ok_and(|f| !f.is_nullable())
}) {
return Err(Error::Schema {
message: format!(
"computed column '{name}' of view '{}' cannot hold NULL; recreate the view",
view.name()
),
});
}
ensure_declarations_are_planned(&physical)?;
let physical_planned: Vec<&FieldRef> = physical
.fields()
.iter()
.filter(|f| computed_column_from_field(f).is_none())
.collect();
let matches = planned_fields.len() == physical_planned.len()
&& planned_fields.iter().zip(&physical_planned).all(|(e, p)| {
e.name() == p.name()
&& e.data_type() == p.data_type()
&& (p.is_nullable() || !e.is_nullable())
});
if !matches {
return Err(Error::Schema {
message: format!(
"the stored definition of view '{}' does not produce this \
view's schema; recreate the view",
view.name()
),
});
}
let current = definition_to_metadata(&replanned)?;
let persist = view_ds.schema().metadata.get(DEFINITION_META_KEY) != Some(¤t);
let unnest = super::physical_unnest(&replanned, staging.as_ref())?;
let definition_changed = !same_meaning(&source_schema, definition, &replanned, unnest.as_ref());
let definition = &replanned;
let persist = persist.then_some(definition);
if definition_changed {
return rebuild(
view_native,
&view_ds,
&source_ds,
source_version,
source_ts,
definition,
&inputs,
unnest.as_ref(),
true,
expected_incarnation,
)
.await;
}
let metadata = &view_ds.schema().metadata;
let watermark: Option<u64> = metadata
.get(SOURCE_VERSION_META_KEY)
.and_then(|raw| raw.parse().ok());
let recorded_ts: Option<u128> = metadata
.get(SOURCE_VERSION_TS_META_KEY)
.and_then(|raw| raw.parse().ok());
let recorded_view_version = metadata
.get(VIEW_VERSION_META_KEY)
.and_then(|raw| raw.parse::<u64>().ok());
let view_intact = match recorded_view_version {
Some(recorded) if recorded == view_ds.version().version => true,
Some(recorded) if recorded < view_ds.version().version => {
only_computed_rewrites_since(&view_ds, recorded).await?
}
_ => false,
};
if !full && watermark == Some(source_version) && view_intact && recorded_ts == Some(source_ts) {
return Ok(RefreshMaterializedViewResult {
mode: RefreshMode::NoOp,
rows_written: 0,
source_version,
version: view_ds.version().version,
});
}
if definition.is_grouped() {
return rebuild(
view_native,
&view_ds,
&source_ds,
source_version,
source_ts,
definition,
&inputs,
None,
persist.is_some(),
expected_incarnation,
)
.await;
}
let watermark = watermark.filter(|_| view_intact);
match plan_increment(
&source_ds,
source_version,
watermark,
recorded_ts,
full,
definition,
&inputs,
)
.await
{
Some(increment) => {
let reconciled = incremental(
view_native,
&view_ds,
&source_ds,
source_version,
source_ts,
increment,
definition,
&inputs,
unnest.as_ref(),
persist,
watermark,
expected_incarnation,
)
.await?;
match reconciled {
Some(result) => Ok(result),
None => {
rebuild(
view_native,
&view_ds,
&source_ds,
source_version,
source_ts,
definition,
&inputs,
unnest.as_ref(),
persist.is_some(),
expected_incarnation,
)
.await
}
}
}
None => {
rebuild(
view_native,
&view_ds,
&source_ds,
source_version,
source_ts,
definition,
&inputs,
unnest.as_ref(),
persist.is_some(),
expected_incarnation,
)
.await
}
}
}
async fn plan_increment(
source_ds: &Dataset,
source_version: u64,
watermark: Option<u64>,
recorded_ts: Option<u128>,
full: bool,
definition: &MaterializedViewDefinition,
inputs: &[String],
) -> Option<Increment> {
if full {
return None;
}
let watermark = watermark?;
if watermark > source_version {
return None;
}
let old = source_ds.checkout_version(watermark).await.ok()?;
if recorded_ts != Some(old.manifest.timestamp_nanos) {
return None;
}
let old_ids: HashSet<u64> = old.get_fragments().iter().map(|f| f.id() as u64).collect();
let live: Vec<Fragment> = source_ds
.get_fragments()
.iter()
.map(|f| f.metadata().clone())
.collect();
if let Some(delta) = appends_and_rewrites(source_ds, watermark, source_version).await {
let folded = delta
.rewritten
.iter()
.any(|id| !old_ids.contains(id) && !delta.produced.contains(id));
if folded {
return None;
}
if delta
.updated_in_place
.iter()
.any(|id| !old_ids.contains(id))
{
return None;
}
if definition.limit.is_some() && (delta.deleted_rows || delta.updated_rows) {
return None;
}
if delta.updated_rows
&& source_ds.manifest.data_storage_format.lance_file_format() == ConcreteFileVersion::V1
{
return None;
}
return Some(Increment {
appended: live
.into_iter()
.filter(|f| !old_ids.contains(&f.id) && !delta.produced.contains(&f.id))
.collect(),
evict_deleted: delta.deleted_rows,
replace_updated: delta.updated_rows,
});
}
is_pure_append(&old, source_ds, &relevant_field_ids(source_ds, inputs)).then(|| Increment {
appended: live
.into_iter()
.filter(|f| !old_ids.contains(&f.id))
.collect(),
evict_deleted: false,
replace_updated: false,
})
}
const MAX_TRANSACTION_WALK: u64 = 512;
struct Increment {
appended: Vec<Fragment>,
evict_deleted: bool,
replace_updated: bool,
}
struct TxnDelta {
rewritten: HashSet<u64>,
produced: HashSet<u64>,
deleted_rows: bool,
updated_rows: bool,
updated_in_place: HashSet<u64>,
}
async fn appends_and_rewrites(cur: &Dataset, from: u64, to: u64) -> Option<TxnDelta> {
if to <= from || to - from > MAX_TRANSACTION_WALK {
return None;
}
let mut delta = TxnDelta {
rewritten: HashSet::new(),
produced: HashSet::new(),
deleted_rows: false,
updated_rows: false,
updated_in_place: HashSet::new(),
};
for version in (from + 1)..=to {
let Ok(Some(txn)) = cur.read_transaction_by_version(version).await else {
return None;
};
match txn.operation {
Operation::Append { .. } | Operation::ReserveFragments { .. } => {}
Operation::Delete { .. } => delta.deleted_rows = true,
Operation::Update {
removed_fragment_ids,
new_fragments,
updated_fragments,
..
} => {
delta.updated_rows = true;
delta.deleted_rows = true;
delta.rewritten.extend(removed_fragment_ids.iter().copied());
delta
.produced
.extend(updated_fragments.iter().map(|f| f.id));
delta
.updated_in_place
.extend(updated_fragments.iter().map(|f| f.id));
let _ = new_fragments;
}
Operation::Rewrite { groups, .. } => {
for group in groups {
delta
.rewritten
.extend(group.old_fragments.iter().map(|f| f.id));
delta
.produced
.extend(group.new_fragments.iter().map(|f| f.id));
}
}
_ => return None,
}
}
Some(delta)
}
fn is_pure_append(old: &Dataset, cur: &Dataset, relevant: &HashSet<i32>) -> bool {
let signature = |fragment: &lance::dataset::fragment::FileFragment| {
fragment_signature(fragment.metadata(), relevant)
};
let current: HashSet<(u64, String)> = cur.get_fragments().iter().map(signature).collect();
old.get_fragments()
.iter()
.all(|fragment| current.contains(&signature(fragment)))
}
fn fragment_signature(metadata: &Fragment, relevant: &HashSet<i32>) -> (u64, String) {
let touches_relevant =
|fields: &[i32]| relevant.is_empty() || fields.iter().any(|id| relevant.contains(id));
let mut files: Vec<&str> = metadata
.files
.iter()
.filter(|file| touches_relevant(&file.fields))
.map(|file| file.path.as_str())
.collect();
files.sort_unstable();
let mut overlays: Vec<String> = metadata
.overlays
.iter()
.filter(|overlay| touches_relevant(&overlay.data_file.fields))
.map(|overlay| format!("{}@{}", overlay.data_file.path, overlay.committed_version))
.collect();
overlays.sort_unstable();
(
metadata.id,
format!(
"{}|{}|{:?}",
files.join(","),
overlays.join(","),
metadata.deletion_file
),
)
}
fn relevant_field_ids(source: &Dataset, inputs: &[String]) -> HashSet<i32> {
fn collect(field: &lance_core::datatypes::Field, ids: &mut HashSet<i32>) {
ids.insert(field.id);
for child in &field.children {
collect(child, ids);
}
}
let mut ids = HashSet::new();
for input in inputs {
if let Some(field) = source.schema().field(input) {
collect(field, &mut ids);
}
}
ids
}
fn same_meaning(
source_schema: &SchemaRef,
stored: &MaterializedViewDefinition,
replanned: &MaterializedViewDefinition,
unnest: Option<&super::ViewUnnest>,
) -> bool {
if stored.source_table != replanned.source_table
|| stored.source_namespace != replanned.source_namespace
|| stored.lateral != replanned.lateral
|| stored.limit != replanned.limit
|| stored.projections.len() != replanned.projections.len()
|| stored.filter.is_some() != replanned.filter.is_some()
|| stored.group_by.len() != replanned.group_by.len()
{
return false;
}
if stored.is_grouped() {
return stored.projections == replanned.projections
&& stored.group_by == replanned.group_by
&& stored.filter == replanned.filter;
}
let schema = match unnest {
None => source_schema.clone(),
Some(unnest) => match super::flattened_schema(source_schema, unnest) {
Ok(schema) => schema,
Err(_) => return false,
},
};
let planner = Planner::new(schema);
let same_expr = |a: &str, b: &str| match (planner.parse_expr(a), planner.parse_expr(b)) {
(Ok(a), Ok(b)) => a == b,
_ => false,
};
stored
.projections
.iter()
.zip(&replanned.projections)
.all(|(a, b)| a.output == b.output && same_expr(&a.expression, &b.expression))
&& match (&stored.filter, &replanned.filter) {
(Some(a), Some(b)) => same_expr(a, b),
_ => true,
}
}
pub(crate) async fn ensure_no_mem_wal(dataset: &Dataset, role: &str, name: &str) -> Result<()> {
let retained = !dataset.list_mem_wal_latest_shard_ids().await?.is_empty();
if retained || dataset.mem_wal_index_details().await?.is_some() {
return Err(Error::NotSupported {
message: format!(
"{role} '{name}' has an LSM write spec or retained un-compacted \
rows: rows in un-compacted tiers are invisible to refresh"
),
});
}
Ok(())
}
pub(super) async fn open_source(
view: &Table,
definition: &MaterializedViewDefinition,
staging: Option<&super::StagingBinding>,
) -> Result<Dataset> {
let database = view.database_opt().ok_or_else(|| Error::InvalidInput {
message: "the view was not opened through a database connection".into(),
})?;
let (name, namespace_path) = match staging {
Some(staging) => (staging.table.clone(), staging.namespace.clone()),
None => (
definition.source_table.clone(),
definition.source_namespace.clone(),
),
};
let source = database
.open_table(OpenTableRequest {
name,
namespace_path,
index_cache_size: None,
lance_read_params: None,
location: None,
namespace_client: None,
managed_versioning: None,
})
.await?;
let native = source.as_native().ok_or_else(|| Error::NotSupported {
message: "materialized views are supported only on local tables".into(),
})?;
let dataset = native.dataset.get().await?.as_ref().clone();
if definition.vector_source.is_none() && !dataset.manifest.uses_stable_row_ids() {
return Err(Error::InvalidInput {
message: format!(
"source table '{}' does not have stable row ids; it is not the \
table this view was declared over",
definition.source_table
),
});
}
Ok(dataset)
}
#[allow(clippy::too_many_arguments)]
async fn incremental(
view_native: &NativeTable,
view_ds: &Dataset,
source_ds: &Dataset,
source_version: u64,
source_ts: u128,
increment: Increment,
definition: &MaterializedViewDefinition,
inputs: &[String],
unnest: Option<&super::ViewUnnest>,
persist: Option<&MaterializedViewDefinition>,
watermark: Option<u64>,
expected_incarnation: Option<&str>,
) -> Result<Option<RefreshMaterializedViewResult>> {
let new_fragments = increment.appended;
let watermark_version = watermark.unwrap_or(0);
let mut eviction = Eviction::new(view_ds, EVICTION_CHUNK);
let mut updated_rows = false;
if (increment.evict_deleted || increment.replace_updated)
&& let Some(watermark) = watermark
{
let delta = source_ds
.delta()
.with_begin_version(watermark)
.with_end_version(source_version)
.build()?;
let cap = eviction_rebuild_cap();
let mut evicted = 0usize;
if increment.evict_deleted {
let mut stream = delta.get_deleted_row_ids().await?;
while let Some(batch) = stream.try_next().await? {
let ids = row_ids_of(&batch)?;
evicted += ids.len();
if evicted > cap {
return Ok(None);
}
eviction.push(ids).await?;
}
}
if increment.replace_updated {
let mut scanner = source_ds.scan();
scanner.with_row_id().project(&[ROW_CREATED_AT_VERSION])?;
scanner.batch_size(8192);
scanner.filter(&format!(
"{ROW_CREATED_AT_VERSION} <= {watermark}
AND {ROW_LAST_UPDATED_AT_VERSION} > {watermark}
AND {ROW_LAST_UPDATED_AT_VERSION} <= {source_version}"
))?;
let mut stream = scanner.try_into_stream().await?;
while let Some(batch) = stream.try_next().await? {
let ids = row_ids_of(&batch)?;
evicted += ids.len();
if evicted > cap {
return Ok(None);
}
updated_rows |= !ids.is_empty();
eviction.push(ids).await?;
}
}
}
let eviction = eviction.finish().await?;
let remaining = match definition.limit {
Some(limit) => {
let held = view_ds.count_rows(None).await? as u64;
Some(limit.saturating_sub(held))
}
None => None,
};
let mut result = RefreshMaterializedViewResult {
mode: RefreshMode::Incremental,
rows_written: 0,
source_version,
version: view_ds.version().version,
};
let nothing_to_add = (new_fragments.is_empty() && !updated_rows) || remaining == Some(0);
if nothing_to_add && eviction.is_none() {
result.version = stamp_watermark(
view_native,
view_ds.clone(),
source_version,
source_ts,
persist,
expected_incarnation,
)
.await?;
return Ok(Some(result));
}
if nothing_to_add {
let filter = refresh_filter(&empty_keys(view_ds)?)?;
let published = publish(
view_ds,
eviction,
Vec::new(),
Some(filter),
expected_incarnation,
)
.await?;
result.version = stamp_watermark(
view_native,
published,
source_version,
source_ts,
persist,
expected_incarnation,
)
.await?;
return Ok(Some(result));
}
let schema = Arc::new(ArrowSchema::from(view_ds.schema()));
let rows_written = Arc::new(AtomicU64::new(0));
let computed = Arc::new(AtomicU64::new(0));
let mut stream = compute_stream(
source_ds,
definition,
inputs,
unnest,
RowScope {
fragments: Some(new_fragments),
created_after: increment.replace_updated.then_some(watermark_version),
limit: remaining,
..Default::default()
},
schema.clone(),
computed.clone(),
)
.await?;
if updated_rows {
let recomputed = compute_stream(
source_ds,
definition,
inputs,
unnest,
RowScope {
updated_between: Some((watermark_version, source_version)),
..Default::default()
},
schema.clone(),
computed.clone(),
)
.await?;
stream = Box::pin(RecordBatchStreamAdapter::new(
schema.clone(),
recomputed.chain(stream),
));
}
let Some(first) = stream.try_next().await? else {
let published = if eviction.is_some() {
publish(
view_ds,
eviction,
Vec::new(),
Some(refresh_filter(&empty_keys(view_ds)?)?),
expected_incarnation,
)
.await?
} else {
view_ds.clone()
};
result.version = stamp_watermark(
view_native,
published,
source_version,
source_ts,
persist,
expected_incarnation,
)
.await?;
return Ok(Some(result));
};
let stream: SendableRecordBatchStream = Box::pin(RecordBatchStreamAdapter::new(
schema,
futures::stream::iter([Ok(first)]).chain(stream),
));
let keys = Arc::new(StdMutex::new(KeyExistenceFilterBuilder::new(vec![
source_row_id_field_id(view_ds)?,
])));
let stream = collect_source_row_ids(stream, keys.clone(), rows_written.clone());
let ds = Arc::new(view_ds.clone());
let write_txn = InsertBuilder::new(WriteDestination::Dataset(ds.clone()))
.with_params(&WriteParams {
mode: WriteMode::Append,
..Default::default()
})
.execute_uncommitted_stream(stream)
.await?;
let Operation::Append {
fragments: new_fragments,
} = write_txn.operation
else {
return Err(Error::Runtime {
message: "expected an append when staging the view's new rows".into(),
});
};
let filter = refresh_filter(&keys)?;
let appended = publish(
view_ds,
eviction,
new_fragments,
Some(filter),
expected_incarnation,
)
.await?;
result.rows_written = rows_written.load(Ordering::Relaxed);
result.version = stamp_watermark(
view_native,
appended,
source_version,
source_ts,
persist,
expected_incarnation,
)
.await?;
Ok(Some(result))
}
#[allow(clippy::too_many_arguments)]
async fn rebuild(
view_native: &NativeTable,
view_ds: &Dataset,
source_ds: &Dataset,
source_version: u64,
source_ts: u128,
definition: &MaterializedViewDefinition,
inputs: &[String],
unnest: Option<&super::ViewUnnest>,
persist_definition: bool,
expected_incarnation: Option<&str>,
) -> Result<RefreshMaterializedViewResult> {
let rows_written = Arc::new(AtomicU64::new(0));
let schema = Arc::new(ArrowSchema::from(view_ds.schema()));
let stream = compute_stream(
source_ds,
definition,
inputs,
unnest,
RowScope {
limit: definition.limit,
..Default::default()
},
schema,
rows_written.clone(),
)
.await?;
let keys = Arc::new(StdMutex::new(KeyExistenceFilterBuilder::new(vec![
source_row_id_field_id(view_ds)?,
])));
let stream = collect_source_row_ids(stream, keys.clone(), Arc::new(AtomicU64::new(0)));
let replaced =
replace_retaining_indices(view_ds.clone(), stream, keys, expected_incarnation).await?;
let version = stamp_watermark(
view_native,
replaced,
source_version,
source_ts,
persist_definition.then_some(definition),
expected_incarnation,
)
.await?;
Ok(RefreshMaterializedViewResult {
mode: RefreshMode::Rebuild,
rows_written: rows_written.load(Ordering::Relaxed),
source_version,
version,
})
}
async fn replace_retaining_indices(
view_ds: Dataset,
stream: SendableRecordBatchStream,
keys: Arc<StdMutex<KeyExistenceFilterBuilder>>,
expected_incarnation: Option<&str>,
) -> Result<Dataset> {
let ds = Arc::new(view_ds);
let read_version = ds.version().version;
#[cfg(test)]
tests::hold_before_publish(ds.uri()).await;
ensure_incarnation(&ds, expected_incarnation, ds.uri()).await?;
let removed_fragment_ids: Vec<u64> = ds.get_fragments().iter().map(|f| f.id() as u64).collect();
let write_txn = InsertBuilder::new(WriteDestination::Dataset(ds.clone()))
.with_params(&WriteParams {
mode: WriteMode::Append,
..Default::default()
})
.execute_uncommitted_stream(stream)
.await?;
let Operation::Append {
fragments: new_fragments,
} = write_txn.operation
else {
return Err(Error::Runtime {
message: "expected an append when staging the view's replacement rows".into(),
});
};
let filter = refresh_filter(&keys)?;
let transaction = Transaction::new(
read_version,
Operation::Update {
removed_fragment_ids,
updated_fragments: Vec::new(),
new_fragments,
fields_modified: Vec::new(),
compacted_sstables: Vec::new(),
fields_for_preserving_frag_bitmap: Vec::new(),
update_mode: None,
inserted_rows_filter: Some(filter),
updated_fragment_offsets: None,
},
None,
);
let committed = CommitBuilder::new(WriteDestination::Dataset(ds))
.execute(transaction)
.await?;
if committed.version().version != read_version + 1 {
return Err(Error::Runtime {
message: format!(
"a concurrent commit raced this refresh (view version {}); the
refresh is unrecorded and the next one will rebuild",
committed.version().version
),
});
}
Ok(committed)
}
pub(super) async fn ensure_incarnation(
view_ds: &Dataset,
expected: Option<&str>,
what: &str,
) -> Result<()> {
let Some(expected) = expected else {
return Ok(());
};
let mut latest = view_ds.clone();
latest.checkout_latest().await?;
match latest.schema().metadata.get(INCARNATION_META_KEY) {
Some(actual) if actual == expected => Ok(()),
Some(_) => Err(Error::Runtime {
message: format!(
"materialized view '{what}' is not the incarnation this refresh was \
requested for: it was dropped and recreated"
),
}),
None => Err(Error::Runtime {
message: format!(
"materialized view '{what}' carries no incarnation token: its schema \
metadata was replaced since the token was captured"
),
}),
}
}
pub(super) async fn stamp_watermark(
view_native: &NativeTable,
mut dataset: Dataset,
source_version: u64,
source_ts: u128,
definition: Option<&MaterializedViewDefinition>,
expected_incarnation: Option<&str>,
) -> Result<u64> {
ensure_incarnation(&dataset, expected_incarnation, dataset.uri()).await?;
let predicted = dataset.version().version + 1;
let incarnation = dataset
.schema()
.metadata
.get(INCARNATION_META_KEY)
.cloned()
.unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
let mut metadata = vec![(INCARNATION_META_KEY.to_string(), Some(incarnation))];
if let Some(definition) = definition {
metadata.push((
DEFINITION_META_KEY.to_string(),
Some(definition_to_metadata(definition)?),
));
}
metadata.extend([
(
SOURCE_VERSION_META_KEY.to_string(),
Some(source_version.to_string()),
),
(
SOURCE_VERSION_TS_META_KEY.to_string(),
Some(source_ts.to_string()),
),
(
REFRESHED_AT_MS_META_KEY.to_string(),
Some(now_ms().to_string()),
),
(
VIEW_VERSION_META_KEY.to_string(),
Some(predicted.to_string()),
),
]);
dataset.update_schema_metadata(metadata).await?;
let actual = dataset.version().version;
if actual != predicted {
return Err(Error::Runtime {
message: format!(
"a concurrent commit raced this refresh (view version {actual}, \
expected {predicted}); the refresh is unrecorded and the next \
one will rebuild"
),
});
}
view_native.dataset.update(dataset);
Ok(predicted)
}
fn now_ms() -> u128 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_millis()
}
#[derive(Default)]
struct RowScope {
fragments: Option<Vec<Fragment>>,
created_after: Option<u64>,
updated_between: Option<(u64, u64)>,
limit: Option<u64>,
}
async fn only_computed_rewrites_since(view_ds: &Dataset, recorded: u64) -> Result<bool> {
let physical = ArrowSchema::from(view_ds.schema());
fn subtree(field: &lance_core::datatypes::Field, ids: &mut Vec<u32>) {
ids.push(field.id as u32);
for child in &field.children {
subtree(child, ids);
}
}
let mut computed_fields = Vec::new();
for column in computed_columns(&physical) {
if let Some(field) = view_ds.schema().field(&column.name) {
subtree(field, &mut computed_fields);
}
}
if computed_fields.is_empty() {
return Ok(false);
}
for version in recorded + 1..=view_ds.version().version {
let Some(transaction) = view_ds.read_transaction_by_version(version).await? else {
return Ok(false);
};
let fill = match &transaction.operation {
Operation::Update {
removed_fragment_ids,
new_fragments,
fields_modified,
update_mode: Some(UpdateMode::RewriteColumns),
..
} => {
removed_fragment_ids.is_empty()
&& new_fragments.is_empty()
&& !fields_modified.is_empty()
&& fields_modified
.iter()
.all(|field| computed_fields.contains(field))
}
Operation::DataReplacement { replacements } => {
!replacements.is_empty()
&& replacements.iter().all(|group| {
!group.1.fields.is_empty()
&& group
.1
.fields
.iter()
.all(|field| computed_fields.contains(&(*field as u32)))
})
}
Operation::UpdateConfig {
config_updates: None,
table_metadata_updates: None,
schema_metadata_updates: None,
field_metadata_updates,
} => {
!field_metadata_updates.is_empty()
&& field_metadata_updates
.keys()
.all(|field| computed_fields.contains(&(*field as u32)))
}
_ => false,
};
if !fill {
return Ok(false);
}
}
Ok(true)
}
async fn compute_stream(
source: &Dataset,
definition: &MaterializedViewDefinition,
inputs: &[String],
unnest: Option<&super::ViewUnnest>,
scope: RowScope,
schema: SchemaRef,
rows_written: Arc<AtomicU64>,
) -> Result<SendableRecordBatchStream> {
if definition.is_grouped() {
return super::grouped::stream(source, definition, inputs, schema, rows_written).await;
}
let RowScope {
fragments,
created_after,
updated_between,
limit,
} = scope;
let mut scanner = source.scan();
if let Some(fragments) = fragments {
scanner.with_fragments(fragments);
}
scanner.with_row_id();
let updated_filter = updated_between.map(|(from, to)| {
format!(
"{ROW_CREATED_AT_VERSION} <= {from} \
AND {ROW_LAST_UPDATED_AT_VERSION} > {from} \
AND {ROW_LAST_UPDATED_AT_VERSION} <= {to}"
)
});
let created_filter =
created_after.map(|version| format!("{ROW_CREATED_AT_VERSION} > {version}"));
let clauses: Vec<String> = definition
.filter
.clone()
.filter(|_| unnest.is_none())
.map(|f| format!("({f})"))
.into_iter()
.chain(updated_filter)
.chain(created_filter)
.collect();
if !clauses.is_empty() {
scanner.filter(&clauses.join(" AND "))?;
}
let expanded = match unnest {
Some(unnest) => Some(UnnestPlan::new(source, definition, inputs, unnest)?),
None => {
let transforms: Vec<(&str, &str)> = definition
.projections
.iter()
.map(|p| (p.output.as_str(), p.expression.as_str()))
.collect();
scanner.project_with_transform(&transforms)?;
None
}
};
if let Some(expanded) = &expanded {
scanner.project(&expanded.raw_inputs)?;
}
if limit == Some(0) {
return Ok(Box::pin(RecordBatchStreamAdapter::new(
schema.clone(),
futures::stream::empty(),
)));
}
if let Some(limit) = limit {
let limit = i64::try_from(limit).map_err(|_| Error::InvalidInput {
message: format!("view limit {limit} exceeds the maximum of {}", i64::MAX),
})?;
scanner.limit(Some(limit), None)?;
}
let out_schema = schema.clone();
let mapped = scanner.try_into_stream().await?.map(move |batch| {
let batch = batch.map_err(|e| DataFusionError::External(Box::new(e)))?;
let batch = match &expanded {
None => batch,
Some(expanded) => expanded.apply(&batch)?,
};
let batch = to_view_batch(&batch, &out_schema)?;
rows_written.fetch_add(batch.num_rows() as u64, Ordering::Relaxed);
Ok(batch)
});
Ok(Box::pin(RecordBatchStreamAdapter::new(schema, mapped)))
}
pub(super) fn to_view_batch(
batch: &RecordBatch,
out_schema: &SchemaRef,
) -> datafusion::common::Result<RecordBatch> {
let mut columns = Vec::with_capacity(out_schema.fields().len());
for field in out_schema.fields() {
if computed_column_from_field(field).is_some() {
columns.push(new_null_array(field.data_type(), batch.num_rows()));
continue;
}
let name = if field.name() == SOURCE_ROW_ID_COLUMN {
ROW_ID
} else {
field.name()
};
let column = batch.column_by_name(name).ok_or_else(|| {
DataFusionError::Internal(format!(
"view column '{}' is not produced by the view's definition",
field.name()
))
})?;
columns.push(column.clone());
}
Ok(RecordBatch::try_new(out_schema.clone(), columns)?)
}
struct UnnestPlan {
column: String,
raw_inputs: Vec<String>,
read_schema: SchemaRef,
projections: Vec<(String, Arc<dyn PhysicalExpr>)>,
filter: Option<Arc<dyn PhysicalExpr>>,
}
impl UnnestPlan {
fn new(
source: &Dataset,
definition: &MaterializedViewDefinition,
inputs: &[String],
unnest: &super::ViewUnnest,
) -> Result<Self> {
let mut raw_inputs: Vec<String> = inputs
.iter()
.map(|input| super::root(input).to_string())
.chain(std::iter::once(unnest.column.clone()))
.collect();
raw_inputs.sort();
raw_inputs.dedup();
let flattened = super::flattened_schema(&ArrowSchema::from(source.schema()), unnest)?;
let mut read_fields = Vec::with_capacity(raw_inputs.len() + 1);
for name in &raw_inputs {
let name = if *name == unnest.column {
&unnest.alias
} else {
name
};
let field = flattened
.field_with_name(name)
.map_err(|_| Error::Runtime {
message: format!("source column '{name}' read by the view is missing"),
})?;
read_fields.push(field.clone());
}
read_fields.push(ArrowField::new(ROW_ID, DataType::UInt64, false));
let read_schema = Arc::new(ArrowSchema::new(read_fields));
let planner = Planner::new(read_schema.clone());
let physical = |what: &str, sql: &str| -> Result<Arc<dyn PhysicalExpr>> {
let err = |e: lance::Error| Error::Runtime {
message: format!("{what}: {e}"),
};
let parsed = planner.parse_expr(sql).map_err(err)?;
let optimized = planner.optimize_expr(parsed).map_err(err)?;
planner.create_physical_expr(&optimized).map_err(err)
};
let mut projections = Vec::with_capacity(definition.projections.len());
for projection in &definition.projections {
let expr = physical(
&format!("view column '{}'", projection.output),
&projection.expression,
)?;
projections.push((projection.output.clone(), expr));
}
let filter = definition
.filter
.as_deref()
.map(|sql| physical("view filter", sql))
.transpose()?;
Ok(Self {
column: unnest.column.clone(),
raw_inputs,
read_schema,
projections,
filter,
})
}
fn apply(&self, batch: &RecordBatch) -> datafusion::common::Result<RecordBatch> {
let unnested = unnest_batch(batch, &self.column)
.map_err(|e| DataFusionError::External(Box::new(e)))?;
let unnested = RecordBatch::try_new(self.read_schema.clone(), unnested.columns().to_vec())?;
let unnested = match &self.filter {
None => unnested,
Some(filter) => {
let keep = filter
.evaluate(&unnested)?
.into_array(unnested.num_rows())?;
let keep = keep.as_boolean_opt().ok_or_else(|| {
DataFusionError::Internal("view filter did not evaluate to a boolean".into())
})?;
arrow_select::filter::filter_record_batch(&unnested, keep)?
}
};
let mut columns = Vec::with_capacity(self.projections.len() + 1);
for (output, expr) in &self.projections {
let value = expr.evaluate(&unnested)?.into_array(unnested.num_rows())?;
columns.push((output.clone(), value));
}
let row_id = unnested
.column_by_name(ROW_ID)
.expect("scan carries the row id")
.clone();
columns.push((ROW_ID.to_string(), row_id));
Ok(RecordBatch::try_from_iter(columns)?)
}
}
fn unnest_batch(batch: &RecordBatch, list_column: &str) -> Result<RecordBatch> {
use arrow_array::{Array, ListArray, UInt32Array};
use arrow_select::take::take;
let (list_index, _) = batch
.schema()
.column_with_name(list_column)
.ok_or_else(|| Error::Runtime {
message: format!("expansion column '{list_column}' is not in the batch"),
})?;
let list = batch
.column(list_index)
.as_any()
.downcast_ref::<ListArray>()
.ok_or_else(|| Error::Runtime {
message: format!(
"expansion column '{list_column}' is {}, not a list",
batch.column(list_index).data_type()
),
})?;
let offsets = list.value_offsets();
let mut repeat = Vec::with_capacity(list.values().len());
for row in 0..list.len() {
if list.is_valid(row) {
let count = (offsets[row + 1] - offsets[row]) as usize;
repeat.extend(std::iter::repeat_n(row as u32, count));
}
}
let repeat = UInt32Array::from(repeat);
let elements = {
let mut ranges = Vec::new();
for row in 0..list.len() {
if list.is_valid(row) {
ranges.extend((offsets[row] as u32)..(offsets[row + 1] as u32));
}
}
take(list.values().as_ref(), &UInt32Array::from(ranges), None)?
};
let mut fields = Vec::with_capacity(batch.num_columns());
let mut columns = Vec::with_capacity(batch.num_columns());
for (index, field) in batch.schema().fields().iter().enumerate() {
if index == list_index {
let element = match field.data_type() {
arrow_schema::DataType::List(element) => element.clone(),
other => {
return Err(Error::Runtime {
message: format!("expansion column '{list_column}' is {other}, not a list"),
});
}
};
fields.push(Arc::new(
arrow_schema::Field::new(field.name(), element.data_type().clone(), true)
.with_metadata(field.metadata().clone()),
));
columns.push(elements.clone());
} else {
fields.push(field.clone());
columns.push(take(batch.column(index).as_ref(), &repeat, None)?);
}
}
let schema = Arc::new(ArrowSchema::new_with_metadata(
fields,
batch.schema().metadata().clone(),
));
Ok(RecordBatch::try_new(schema, columns)?)
}
async fn publish(
view_ds: &Dataset,
eviction: Option<(Vec<Fragment>, Vec<u64>)>,
new_fragments: Vec<Fragment>,
keys: Option<KeyExistenceFilter>,
expected_incarnation: Option<&str>,
) -> Result<Dataset> {
let planned = view_ds.version().version;
#[cfg(test)]
tests::hold_before_publish(view_ds.uri()).await;
#[cfg(test)]
tests::hold_until_peers_planned();
ensure_incarnation(view_ds, expected_incarnation, view_ds.uri()).await?;
let (updated_fragments, removed_fragment_ids) = eviction.unwrap_or_default();
let committed = CommitBuilder::new(WriteDestination::Dataset(Arc::new(view_ds.clone())))
.execute(Transaction::new(
planned,
Operation::Update {
removed_fragment_ids,
updated_fragments,
new_fragments,
fields_modified: Vec::new(),
compacted_sstables: Vec::new(),
fields_for_preserving_frag_bitmap: Vec::new(),
update_mode: None,
inserted_rows_filter: keys,
updated_fragment_offsets: None,
},
None,
))
.await?;
if committed.version().version != planned + 1 {
return Err(Error::Runtime {
message: format!(
"a concurrent commit raced this refresh (view version {});
the refresh is unrecorded and the next one will rebuild",
committed.version().version
),
});
}
Ok(committed)
}
fn row_ids_of(batch: &RecordBatch) -> Result<&UInt64Array> {
let column = batch.column_by_name(ROW_ID).ok_or_else(|| Error::Runtime {
message: format!("'{ROW_ID}' is missing from a delta batch"),
})?;
column
.as_primitive_opt::<UInt64Type>()
.ok_or_else(|| Error::Runtime {
message: "row ids are not UInt64".into(),
})
}
const REFRESH_TOKEN_ID: u64 = u64::MAX;
fn empty_keys(view_ds: &Dataset) -> Result<Arc<StdMutex<KeyExistenceFilterBuilder>>> {
Ok(Arc::new(StdMutex::new(KeyExistenceFilterBuilder::new(
vec![source_row_id_field_id(view_ds)?],
))))
}
fn refresh_filter(keys: &Arc<StdMutex<KeyExistenceFilterBuilder>>) -> Result<KeyExistenceFilter> {
let mut keys = keys.lock().map_err(|_| Error::Runtime {
message: "the provenance key filter was poisoned mid-refresh".into(),
})?;
keys.insert(KeyValue::UInt64(REFRESH_TOKEN_ID))
.map_err(|e| Error::Runtime {
message: format!("failed to mark the refresh's filter: {e}"),
})?;
Ok(keys.build())
}
fn eviction_rebuild_cap() -> usize {
#[cfg(test)]
if let Some(cap) = tests::eviction_cap_override() {
return cap;
}
4 * 1024 * 1024
}
const EVICTION_CHUNK: usize = 64 * 1024;
struct Eviction {
snapshot: Dataset,
chunk: usize,
updated: HashMap<u64, Fragment>,
removed: Vec<u64>,
pending: Vec<u64>,
staged: bool,
#[cfg(test)]
peak: usize,
}
impl Eviction {
fn new(view_ds: &Dataset, chunk: usize) -> Self {
Self {
snapshot: view_ds.clone(),
chunk,
updated: HashMap::new(),
removed: Vec::new(),
pending: Vec::with_capacity(chunk),
staged: false,
#[cfg(test)]
peak: 0,
}
}
async fn push(&mut self, ids: &UInt64Array) -> Result<()> {
for id in ids.values() {
self.pending.push(*id);
#[cfg(test)]
{
self.peak = self.peak.max(self.pending.len());
}
if self.pending.len() == self.chunk {
self.flush().await?;
}
}
Ok(())
}
async fn flush(&mut self) -> Result<()> {
let mut chunk = std::mem::take(&mut self.pending);
let staged = self.stage(&chunk).await;
chunk.clear();
self.pending = chunk;
staged
}
async fn finish(mut self) -> Result<Option<(Vec<Fragment>, Vec<u64>)>> {
if !self.pending.is_empty() {
self.flush().await?;
}
if !self.staged {
return Ok(None);
}
let mut updated: Vec<Fragment> = self.updated.into_values().collect();
updated.sort_unstable_by_key(|f| f.id);
Ok(Some((updated, self.removed)))
}
async fn stage(&mut self, ids: &[u64]) -> Result<()> {
let (updated, removed) = stage_eviction(&self.snapshot, ids).await?;
self.snapshot = advance(&self.snapshot, &updated);
for fragment in updated {
self.updated.insert(fragment.id, fragment);
}
self.removed.extend(removed);
self.staged = true;
Ok(())
}
}
fn advance(view_ds: &Dataset, updated: &[Fragment]) -> Dataset {
if updated.is_empty() {
return view_ds.clone();
}
let by_id: HashMap<u64, &Fragment> = updated.iter().map(|f| (f.id, f)).collect();
let fragments = view_ds
.manifest
.fragments
.iter()
.map(|f| by_id.get(&f.id).map_or_else(|| f.clone(), |u| (*u).clone()))
.collect();
let mut manifest = view_ds.manifest.as_ref().clone();
manifest.fragments = Arc::new(fragments);
let mut snapshot = view_ds.clone();
snapshot.manifest = Arc::new(manifest);
snapshot
}
async fn stage_eviction(view_ds: &Dataset, ids: &[u64]) -> Result<(Vec<Fragment>, Vec<u64>)> {
let predicate = col(SOURCE_ROW_ID_COLUMN).in_list(
ids.iter()
.map(|id| lit(ScalarValue::UInt64(Some(*id))))
.collect(),
false,
);
let staged = DeleteBuilder::from_expr(Arc::new(view_ds.clone()), predicate)
.execute_uncommitted()
.await?;
let Operation::Delete {
updated_fragments,
deleted_fragment_ids,
..
} = staged.transaction.operation
else {
return Err(Error::Runtime {
message: "expected a delete when staging the view's evictions".into(),
});
};
Ok((updated_fragments, deleted_fragment_ids))
}
fn source_row_id_field_id(view_ds: &Dataset) -> Result<i32> {
view_ds
.schema()
.field(SOURCE_ROW_ID_COLUMN)
.map(|f| f.id)
.ok_or_else(|| Error::Runtime {
message: format!("the view has no '{SOURCE_ROW_ID_COLUMN}' column"),
})
}
fn collect_source_row_ids(
stream: SendableRecordBatchStream,
keys: Arc<StdMutex<KeyExistenceFilterBuilder>>,
written: Arc<AtomicU64>,
) -> SendableRecordBatchStream {
let schema = stream.schema();
let mapped = stream.map(move |batch| {
let batch = batch?;
let column = batch
.column_by_name(SOURCE_ROW_ID_COLUMN)
.ok_or_else(|| {
DataFusionError::Internal(format!(
"'{SOURCE_ROW_ID_COLUMN}' is missing from the rows being written"
))
})?
.as_primitive_opt::<UInt64Type>()
.ok_or_else(|| {
DataFusionError::Internal(format!("'{SOURCE_ROW_ID_COLUMN}' is not a uint64"))
})?;
let mut keys = keys
.lock()
.map_err(|_| DataFusionError::Internal("provenance key filter poisoned".into()))?;
for id in column.values() {
keys.insert(KeyValue::UInt64(*id))
.map_err(|e| DataFusionError::Internal(e.to_string()))?;
}
written.fetch_add(batch.num_rows() as u64, Ordering::Relaxed);
Ok(batch)
});
Box::pin(RecordBatchStreamAdapter::new(schema, mapped))
}
#[cfg(test)]
pub(crate) mod tests {
pub(in crate::materialized_view) async fn hold_before_publish(uri: &str) {
{
let mut target = DRIFT_TARGET.lock().unwrap();
if target.as_deref() != Some(uri) {
return;
}
*target = None;
}
DRIFT_PLANNED.notify_one();
DRIFT_RELEASED.notified().await;
}
pub(super) static EVICTION_CAP: StdMutex<Option<usize>> = StdMutex::new(None);
pub(super) fn eviction_cap_override() -> Option<usize> {
*EVICTION_CAP.lock().unwrap()
}
pub(in crate::materialized_view) static DRIFT_LOCK: tokio::sync::Mutex<()> =
tokio::sync::Mutex::const_new(());
pub(in crate::materialized_view) static DRIFT_TARGET: StdMutex<Option<String>> =
StdMutex::new(None);
pub(in crate::materialized_view) static DRIFT_PLANNED: tokio::sync::Notify =
tokio::sync::Notify::const_new();
pub(in crate::materialized_view) static DRIFT_RELEASED: tokio::sync::Notify =
tokio::sync::Notify::const_new();
pub(super) fn hold_until_peers_planned() {
let (Ok(dir), Ok(tag), Ok(peers)) = (
std::env::var("MV_RACE_SYNC"),
std::env::var("MV_RACE_TAG"),
std::env::var("MV_RACE_PEERS"),
) else {
return;
};
let dir = std::path::PathBuf::from(dir);
let peers: usize = peers.parse().unwrap();
std::fs::write(dir.join(format!("planned-{tag}")), b"1").unwrap();
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(120);
while planned_count(&dir) < peers {
assert!(
std::time::Instant::now() < deadline,
"peers never reached the commit boundary"
);
std::thread::sleep(std::time::Duration::from_millis(2));
}
}
fn planned_count(dir: &std::path::Path) -> usize {
std::fs::read_dir(dir)
.map(|entries| {
entries
.filter_map(|e| e.ok())
.filter(|e| e.file_name().to_string_lossy().starts_with("planned-"))
.count()
})
.unwrap_or(0)
}
use arrow_array::{Int32Array, record_batch};
use futures::TryStreamExt;
use lance::dataset::NewColumnTransform;
use lance_file::version::LanceFileVersion;
use super::*;
use crate::connect;
use crate::connection::Connection;
use crate::index::Index;
use crate::index::scalar::BTreeIndexBuilder;
use crate::materialized_view::MaterializedView;
use crate::query::{ExecutableQuery, QueryBase, Select};
use crate::table::{CompactionOptions, OptimizeAction};
async fn db_with_source(values: Vec<i32>) -> (Connection, Table) {
let conn = connect("memory://").execute().await.unwrap();
let batch = record_batch!(("x", Int32, values)).unwrap();
let table = conn
.create_table("src", batch)
.write_options(crate::materialized_view::tests::stable_row_ids())
.execute()
.await
.unwrap();
(conn, table)
}
async fn doubled_view(conn: &Connection) -> MaterializedView {
conn.create_materialized_view("doubled", "src")
.with_no_data(true)
.select([("x", "x"), ("twice", "x * 2")])
.execute()
.await
.unwrap()
}
async fn refreshed_doubled(values: Vec<i32>) -> (Connection, Table, MaterializedView) {
let (conn, source) = db_with_source(values).await;
let view = doubled_view(&conn).await;
view.refresh().execute().await.unwrap();
(conn, source, view)
}
fn chunked_batch() -> RecordBatch {
use arrow_array::builder::{Int32Builder, ListBuilder, StringBuilder, StructBuilder};
use arrow_array::{ArrayRef, Int64Array};
use arrow_schema::{DataType, Field, Fields};
let element_fields = Fields::from(vec![
Field::new("chunk", DataType::Utf8, true),
Field::new("ordinal", DataType::Int32, true),
]);
let mut list = ListBuilder::new(StructBuilder::new(
element_fields,
vec![
Box::new(StringBuilder::new()),
Box::new(Int32Builder::new()),
],
));
for chunks in [Some(vec!["a", "b"]), Some(vec![]), None, Some(vec!["c"])] {
match chunks {
Some(chunks) => {
for (i, c) in chunks.iter().enumerate() {
let s = list.values();
s.field_builder::<StringBuilder>(0).unwrap().append_value(c);
s.field_builder::<Int32Builder>(1)
.unwrap()
.append_value(i as i32);
s.append(true);
}
list.append(true);
}
None => list.append(false),
}
}
let meta = arrow_array::StructArray::from(vec![(
Arc::new(Field::new("title", DataType::Utf8, true)),
Arc::new(arrow_array::StringArray::from(vec!["t1", "t2", "t3", "t4"])) as ArrayRef,
)]);
RecordBatch::try_from_iter(vec![
(
"id",
Arc::new(Int64Array::from(vec![1, 2, 3, 4])) as ArrayRef,
),
("meta", Arc::new(meta) as ArrayRef),
("c", Arc::new(list.finish()) as ArrayRef),
])
.unwrap()
}
#[test]
fn unnest_repeats_siblings_per_element_and_drops_empty_lists() {
use arrow_array::{StringArray, StructArray};
use arrow_schema::{DataType, Field, Fields};
let batch = chunked_batch();
let element_fields = Fields::from(vec![
Field::new("chunk", DataType::Utf8, true),
Field::new("ordinal", DataType::Int32, true),
]);
let out = unnest_batch(&batch, "c").unwrap();
assert_eq!(out.num_rows(), 3, "{out:?}");
let ids: Vec<i64> = out["id"]
.as_primitive::<arrow_array::types::Int64Type>()
.values()
.to_vec();
assert_eq!(ids, [1, 1, 4]);
let element = out["c"].as_any().downcast_ref::<StructArray>().unwrap();
let chunks: Vec<&str> = element
.column(0)
.as_any()
.downcast_ref::<StringArray>()
.unwrap()
.iter()
.flatten()
.collect();
assert_eq!(chunks, ["a", "b", "c"]);
let ordinals: Vec<i32> = element
.column(1)
.as_primitive::<arrow_array::types::Int32Type>()
.values()
.to_vec();
assert_eq!(ordinals, [0, 1, 0]);
assert_eq!(
out.schema().field_with_name("c").unwrap().data_type(),
&DataType::Struct(element_fields)
);
}
#[tokio::test]
async fn a_function_in_from_position_refreshes_from_its_staging() {
use arrow_array::StringArray;
let conn = connect("memory://").execute().await.unwrap();
let staging = conn
.create_table("docs__chunk", chunked_batch())
.write_options(crate::materialized_view::tests::stable_row_ids())
.execute()
.await
.unwrap();
let query =
"SELECT id AS doc, e.chunk AS text, e.ordinal FROM docs, chunk(meta.title, 2) AS e";
let definition = MaterializedViewDefinition::from_sql(query).unwrap();
let err = crate::materialized_view::prepare_definition(&staging, definition.clone())
.await
.unwrap_err();
assert!(
matches!(&err, Error::InvalidInput { message } if message.contains("reads 'docs'")),
"{err:?}"
);
let view = crate::materialized_view::prepare_staged_definition(&staging, definition, "c")
.await
.unwrap()
.create("chunks")
.await
.unwrap();
assert_eq!(view.definition().to_sql(), query);
let metadata = view.table().schema().await.unwrap().metadata().clone();
assert_eq!(
crate::materialized_view::read_staging(&metadata).unwrap(),
Some(crate::materialized_view::StagingBinding {
table: "docs__chunk".into(),
namespace: Vec::new(),
column: "c".into(),
})
);
view.refresh().execute().await.unwrap();
assert_eq!(read(view.table(), "ordinal").await, [0, 0, 1]);
let batches = view
.table()
.query()
.select(Select::columns(&["text"]))
.execute()
.await
.unwrap()
.try_collect::<Vec<_>>()
.await
.unwrap();
let out = arrow_select::concat::concat_batches(&batches[0].schema(), &batches).unwrap();
let texts: Vec<&str> = out["text"]
.as_any()
.downcast_ref::<StringArray>()
.unwrap()
.iter()
.flatten()
.collect();
assert_eq!(texts, ["a", "b", "c"]);
staging.add(chunked_batch()).execute().await.unwrap();
let reopened = conn.open_materialized_view("chunks").await.unwrap();
assert_eq!(reopened.definition().to_sql(), query);
let result = reopened.refresh().execute().await.unwrap();
assert_eq!(result.mode, RefreshMode::Incremental);
assert_eq!(read(reopened.table(), "ordinal").await, [0, 0, 0, 0, 1, 1]);
}
#[tokio::test]
async fn an_expanded_view_materializes_one_row_per_element() {
use arrow_array::{Int64Array, StringArray};
let conn = connect("memory://").execute().await.unwrap();
let source = conn
.create_table("docs", chunked_batch())
.write_options(crate::materialized_view::tests::stable_row_ids())
.execute()
.await
.unwrap();
let mut view = crate::materialized_view::prepare_definition(
&source,
MaterializedViewDefinition::from_sql(
"SELECT id AS doc, meta.title AS title, e.ordinal + 1 AS nth \
FROM docs, UNNEST(c) AS e WHERE e.ordinal < 5",
)
.unwrap(),
)
.await
.unwrap();
let text = view.input_column("e.chunk").unwrap();
assert!(view.definition.lateral.is_some());
let view = view.create("chunks").await.unwrap();
view.refresh().execute().await.unwrap();
let batches = view
.table()
.query()
.select(Select::columns(&["doc", &text, "title"]))
.execute()
.await
.unwrap()
.try_collect::<Vec<_>>()
.await
.unwrap();
let out = arrow_select::concat::concat_batches(&batches[0].schema(), &batches).unwrap();
let docs: Vec<i64> = out["doc"]
.as_any()
.downcast_ref::<Int64Array>()
.unwrap()
.values()
.to_vec();
let strings = |column: &str| -> Vec<String> {
out[column]
.as_any()
.downcast_ref::<StringArray>()
.unwrap()
.iter()
.flatten()
.map(str::to_string)
.collect()
};
assert_eq!(docs, [1, 1, 4]);
assert_eq!(strings(&text), ["a", "b", "c"]);
assert_eq!(strings("title"), ["t1", "t1", "t4"]);
assert_eq!(read(view.table(), "nth").await, [1, 1, 2]);
source.add(chunked_batch()).execute().await.unwrap();
view.refresh().execute().await.unwrap();
assert_eq!(read(view.table(), "nth").await, [1, 1, 1, 1, 2, 2]);
}
async fn read(table: &Table, column: &str) -> Vec<i32> {
let batches = table
.query()
.select(Select::columns(&[column]))
.execute()
.await
.unwrap()
.try_collect::<Vec<_>>()
.await
.unwrap();
let mut values: Vec<i32> = batches
.iter()
.flat_map(|batch| {
batch[column]
.as_any()
.downcast_ref::<Int32Array>()
.unwrap()
.iter()
.flatten()
.collect::<Vec<_>>()
})
.collect();
values.sort();
values
}
async fn append(table: &Table, values: Vec<i32>) {
let batch = record_batch!(("x", Int32, values)).unwrap();
table.add(batch).execute().await.unwrap();
}
#[tokio::test]
async fn test_first_refresh_materializes_the_view() {
let (conn, _) = db_with_source(vec![1, 2, 3]).await;
let view = doubled_view(&conn).await;
let result = view.refresh().execute().await.unwrap();
assert_eq!(result.mode, RefreshMode::Rebuild);
assert_eq!(result.rows_written, 3);
assert_eq!(read(view.table(), "twice").await, vec![2, 4, 6]);
let reopened = conn.open_materialized_view("doubled").await.unwrap();
let again = reopened.refresh().execute().await.unwrap();
assert_eq!(again.mode, RefreshMode::NoOp);
assert_eq!(again.rows_written, 0);
}
#[tokio::test]
async fn test_filter_selects_the_source_rows() {
let (conn, _) = db_with_source(vec![1, 20, 3, 40]).await;
let view = conn
.create_materialized_view("big", "src")
.with_no_data(true)
.select([("x", "x")])
.only_if("x > 10")
.execute()
.await
.unwrap();
let result = view.refresh().execute().await.unwrap();
assert_eq!(result.rows_written, 2);
assert_eq!(read(view.table(), "x").await, vec![20, 40]);
}
#[tokio::test]
async fn test_mixed_case_filter_is_canonicalized_for_lineage_and_refresh() {
let conn = connect("memory://").execute().await.unwrap();
let batch = record_batch!(
("id", Int32, [1, 2, 3]),
("PartyAbbrev", Utf8, ["D", "R", "D"])
)
.unwrap();
conn.create_table("src", batch)
.write_options(crate::materialized_view::tests::stable_row_ids())
.execute()
.await
.unwrap();
conn.create_materialized_view("democrats", "src")
.with_no_data(true)
.select([("id", "id")])
.only_if(r#""PartyAbbrev" = 'D'"#)
.execute()
.await
.unwrap();
let view = conn.open_materialized_view("democrats").await.unwrap();
assert_eq!(
view.definition().filter.as_deref(),
Some("`PartyAbbrev` = 'D'")
);
let result = view.refresh().execute().await.unwrap();
assert_eq!(result.rows_written, 2);
assert_eq!(read(view.table(), "id").await, vec![1, 3]);
}
#[tokio::test]
async fn a_legacy_layout_with_the_same_meaning_is_rewritten_without_a_rebuild() {
let (conn, source, view) = refreshed_doubled(vec![1]).await;
let legacy = serde_json::json!({
"kind": "select",
"source_table": "src",
"projections": [
{"output": "x", "expression": "`x`"},
{"output": "twice", "expression": "x*2"},
],
"inputs": ["x"],
})
.to_string();
let native = view.table().as_native().unwrap();
let mut dataset = native.dataset.get().await.unwrap().as_ref().clone();
let predicted = dataset.version().version + 1;
dataset
.update_schema_metadata([
(DEFINITION_META_KEY.to_string(), Some(legacy)),
(
VIEW_VERSION_META_KEY.to_string(),
Some(predicted.to_string()),
),
])
.await
.unwrap();
native.dataset.update(dataset);
append(&source, vec![2]).await;
let reopened = conn.open_materialized_view("doubled").await.unwrap();
let result = reopened.refresh().execute().await.unwrap();
assert_eq!(result.mode, RefreshMode::Incremental);
assert_eq!(read(reopened.table(), "twice").await, vec![2, 4]);
let stored: serde_json::Value = serde_json::from_str(
&reopened.table().schema().await.unwrap().metadata()[DEFINITION_META_KEY],
)
.unwrap();
assert_eq!(
stored,
serde_json::json!({
"kind": "query",
"format": 1,
"query": "SELECT x, x * 2 AS twice FROM src",
})
);
}
#[tokio::test]
async fn test_legacy_raw_filter_rebuilds_and_persists_canonical_definition() {
let conn = connect("memory://").execute().await.unwrap();
let batch = record_batch!(
("id", Int32, [1, 2, 3]),
("PartyAbbrev", Utf8, ["D", "R", "D"])
)
.unwrap();
conn.create_table("legacy_src", batch)
.write_options(crate::materialized_view::tests::stable_row_ids())
.execute()
.await
.unwrap();
let view = conn
.create_materialized_view("legacy_view", "legacy_src")
.with_no_data(true)
.select([("id", "id")])
.only_if(r#""PartyAbbrev" = 'X'"#)
.execute()
.await
.unwrap();
assert_eq!(view.refresh().execute().await.unwrap().rows_written, 0);
let legacy = serde_json::json!({
"kind": "select",
"source_table": "legacy_src",
"projections": [{"output": "id", "expression": "id"}],
"filter": r#""PartyAbbrev" = 'D'"#,
"inputs": ["id"],
})
.to_string();
let native = view.table().as_native().unwrap();
let mut dataset = native.dataset.get().await.unwrap().as_ref().clone();
let predicted = dataset.version().version + 1;
dataset
.update_schema_metadata([
(DEFINITION_META_KEY.to_string(), Some(legacy)),
(
VIEW_VERSION_META_KEY.to_string(),
Some(predicted.to_string()),
),
])
.await
.unwrap();
native.dataset.update(dataset);
let reopened = conn.open_materialized_view("legacy_view").await.unwrap();
let result = reopened.refresh().execute().await.unwrap();
assert_eq!(result.mode, RefreshMode::Rebuild);
assert_eq!(result.rows_written, 2);
assert_eq!(read(reopened.table(), "id").await, vec![1, 3]);
let migrated = conn.open_materialized_view("legacy_view").await.unwrap();
assert_eq!(
migrated.definition().filter.as_deref(),
Some("`PartyAbbrev` = 'D'")
);
assert_eq!(
migrated.refresh().execute().await.unwrap().mode,
RefreshMode::NoOp
);
assert_eq!(read(migrated.table(), "id").await, vec![1, 3]);
}
#[tokio::test]
async fn test_append_refreshes_incrementally() {
let (_conn, source, view) = refreshed_doubled(vec![1, 2]).await;
append(&source, vec![5]).await;
let result = view.refresh().execute().await.unwrap();
assert_eq!(result.mode, RefreshMode::Incremental);
assert_eq!(result.rows_written, 1);
assert_eq!(read(view.table(), "twice").await, vec![2, 4, 10]);
}
#[tokio::test]
async fn test_incremental_applies_the_filter() {
let (conn, source) = db_with_source(vec![1, 20]).await;
let view = conn
.create_materialized_view("big", "src")
.with_no_data(true)
.select([("x", "x")])
.only_if("x > 10")
.execute()
.await
.unwrap();
view.refresh().execute().await.unwrap();
append(&source, vec![3, 30]).await;
let result = view.refresh().execute().await.unwrap();
assert_eq!(result.mode, RefreshMode::Incremental);
assert_eq!(result.rows_written, 1);
assert_eq!(read(view.table(), "x").await, vec![20, 30]);
}
#[tokio::test]
async fn test_incremental_with_nothing_matching_advances_the_watermark() {
let (conn, source) = db_with_source(vec![20]).await;
let view = conn
.create_materialized_view("big", "src")
.with_no_data(true)
.select([("x", "x")])
.only_if("x > 10")
.execute()
.await
.unwrap();
view.refresh().execute().await.unwrap();
append(&source, vec![1, 2]).await;
let result = view.refresh().execute().await.unwrap();
assert_eq!(result.mode, RefreshMode::Incremental);
assert_eq!(result.rows_written, 0);
let again = view.refresh().execute().await.unwrap();
assert_eq!(again.mode, RefreshMode::NoOp);
}
#[tokio::test]
async fn test_update_replaces_the_rows_it_changed() {
let (_conn, source, view) = refreshed_doubled(vec![1, 2, 3]).await;
source
.update()
.column("x", "20")
.only_if("x = 2")
.execute()
.await
.unwrap();
let result = view.refresh().execute().await.unwrap();
assert_eq!(result.mode, RefreshMode::Incremental);
assert_eq!(result.rows_written, 1, "only the changed row is recomputed");
assert_eq!(read(view.table(), "twice").await, vec![2, 6, 40]);
}
#[tokio::test]
async fn test_a_legacy_storage_source_is_reconciled() {
let conn = connect("memory://").execute().await.unwrap();
let batch = record_batch!(("x", Int32, [1, 2, 3])).unwrap();
let source = conn
.create_table("legacy_src", batch)
.write_options(crate::table::WriteOptions {
lance_write_params: Some(lance::dataset::WriteParams {
enable_stable_row_ids: true,
data_storage_version: Some(LanceFileVersion::Legacy),
..Default::default()
}),
})
.execute()
.await
.unwrap();
let view = conn
.create_materialized_view("legacy_doubled", "legacy_src")
.with_no_data(true)
.select([("x", "x"), ("twice", "x * 2")])
.execute()
.await
.unwrap();
view.refresh().execute().await.unwrap();
source
.add(record_batch!(("x", Int32, [4])).unwrap())
.execute()
.await
.unwrap();
let result = view.refresh().execute().await.unwrap();
assert_eq!(result.mode, RefreshMode::Incremental);
assert_eq!(read(view.table(), "twice").await, vec![2, 4, 6, 8]);
source
.update()
.column("x", "20")
.only_if("x = 2")
.execute()
.await
.unwrap();
let result = view.refresh().execute().await.unwrap();
assert_eq!(result.mode, RefreshMode::Rebuild);
assert_eq!(read(view.table(), "twice").await, vec![2, 6, 8, 40]);
}
#[tokio::test]
async fn test_delete_evicts_the_view_rows_it_removed() {
let (_conn, source, view) = refreshed_doubled(vec![1, 2, 3]).await;
source.delete("x = 2").await.unwrap();
let result = view.refresh().execute().await.unwrap();
assert_eq!(result.mode, RefreshMode::Incremental);
assert_eq!(read(view.table(), "twice").await, vec![2, 6]);
source.delete("x = 1").await.unwrap();
source
.add(record_batch!(("x", Int32, vec![4])).unwrap())
.execute()
.await
.unwrap();
let result = view.refresh().execute().await.unwrap();
assert_eq!(result.mode, RefreshMode::Incremental);
assert_eq!(read(view.table(), "twice").await, vec![6, 8]);
}
#[tokio::test]
async fn test_merge_insert_by_source_delete_evicts_the_view_rows() {
let (_conn, source, view) = refreshed_doubled(vec![1, 2, 3]).await;
let batch = record_batch!(("x", Int32, vec![1, 3])).unwrap();
let reader = arrow_array::RecordBatchIterator::new(vec![Ok(batch.clone())], batch.schema());
let mut merge = source.merge_insert(&["x"]);
merge.when_not_matched_by_source_delete(None);
merge.execute(Box::new(reader)).await.unwrap();
let result = view.refresh().execute().await.unwrap();
assert_eq!(result.mode, RefreshMode::Incremental);
assert_eq!(read(view.table(), "twice").await, vec![2, 6]);
}
#[tokio::test(flavor = "multi_thread")]
async fn test_refresh_aborts_rather_than_certify_view_drift() {
let _serial = DRIFT_LOCK.lock().await;
let (conn, source) = db_with_source(vec![1, 2, 3]).await;
let view = conn
.create_materialized_view("drifting_view", "src")
.with_no_data(true)
.select([("x", "x"), ("twice", "x * 2")])
.execute()
.await
.unwrap();
view.refresh().execute().await.unwrap();
let certified = source_version_of(view.table()).await;
source.delete("x = 2").await.unwrap();
let uri = view
.table()
.as_native()
.unwrap()
.dataset
.get()
.await
.unwrap()
.uri()
.to_string();
*DRIFT_TARGET.lock().unwrap() = Some(uri);
let refreshing = tokio::spawn(async move { view.refresh().execute().await });
tokio::time::timeout(std::time::Duration::from_secs(30), DRIFT_PLANNED.notified())
.await
.expect("the refresh never reached the publication boundary");
let drifted = conn.open_table("drifting_view").execute().await.unwrap();
drifted.delete("twice = 6").await.unwrap();
DRIFT_RELEASED.notify_one();
let err = refreshing.await.unwrap().unwrap_err();
let message = err.to_string();
assert!(
message.contains("raced this refresh") || message.contains("preempted by concurrent"),
"got {err:?}"
);
assert_eq!(source_version_of(&drifted).await, certified);
}
async fn source_version_of(table: &Table) -> Option<String> {
table
.schema()
.await
.unwrap()
.metadata()
.get(SOURCE_VERSION_META_KEY)
.cloned()
}
#[tokio::test]
async fn test_merge_into_an_empty_source_is_materialized() {
let (_conn, source, view) = refreshed_doubled(vec![]).await;
assert_eq!(read(view.table(), "twice").await, Vec::<i32>::new());
let batch = record_batch!(("x", Int32, vec![1, 2])).unwrap();
let reader = arrow_array::RecordBatchIterator::new(vec![Ok(batch.clone())], batch.schema());
let mut merge = source.merge_insert(&["x"]);
merge
.when_matched_update_all(None)
.when_not_matched_insert_all();
merge.execute(Box::new(reader)).await.unwrap();
view.refresh().execute().await.unwrap();
assert_eq!(read(view.table(), "twice").await, vec![2, 4]);
view.refresh().execute().await.unwrap();
assert_eq!(read(view.table(), "twice").await, vec![2, 4]);
}
#[tokio::test(flavor = "multi_thread")]
async fn test_an_update_is_never_visible_as_a_gap() {
let _serial = DRIFT_LOCK.lock().await;
let (conn, source) = db_with_source(vec![1, 2, 3]).await;
let view = conn
.create_materialized_view("atomic_view", "src")
.with_no_data(true)
.select([("x", "x"), ("twice", "x * 2")])
.execute()
.await
.unwrap();
view.refresh().execute().await.unwrap();
source
.update()
.column("x", "x + 10")
.execute()
.await
.unwrap();
let uri = view
.table()
.as_native()
.unwrap()
.dataset
.get()
.await
.unwrap()
.uri()
.to_string();
*DRIFT_TARGET.lock().unwrap() = Some(uri);
let refreshing = tokio::spawn(async move { view.refresh().execute().await });
tokio::time::timeout(std::time::Duration::from_secs(30), DRIFT_PLANNED.notified())
.await
.expect("the refresh never reached the publication boundary");
let midway = conn.open_table("atomic_view").execute().await.unwrap();
assert_eq!(
read(&midway, "twice").await,
vec![2, 4, 6],
"the pre-refresh rows must still be there in full"
);
DRIFT_RELEASED.notify_one();
refreshing.await.unwrap().unwrap();
let after = conn.open_table("atomic_view").execute().await.unwrap();
assert_eq!(read(&after, "twice").await, vec![22, 24, 26]);
}
async fn provenance_by_x(table: &Table) -> HashMap<i32, u64> {
let batches = table
.query()
.select(Select::columns(&["x", SOURCE_ROW_ID_COLUMN]))
.execute()
.await
.unwrap()
.try_collect::<Vec<_>>()
.await
.unwrap();
batches
.iter()
.flat_map(|batch| {
let xs = batch["x"].as_any().downcast_ref::<Int32Array>().unwrap();
let ids = batch[SOURCE_ROW_ID_COLUMN].as_primitive::<UInt64Type>();
(0..batch.num_rows())
.map(|i| (xs.value(i), ids.value(i)))
.collect::<Vec<_>>()
})
.collect()
}
#[tokio::test]
async fn test_a_chunked_eviction_removes_every_row_it_names() {
let (_conn, _, view) = refreshed_doubled(vec![1, 2, 3, 4, 5, 6]).await;
let native = view.table().as_native().unwrap();
let view_ds = native.dataset.get().await.unwrap().as_ref().clone();
let provenance = provenance_by_x(view.table()).await;
let mut eviction = Eviction::new(&view_ds, 2);
let batch = UInt64Array::from_iter_values([1, 2, 3, 4].map(|x| provenance[&x]));
eviction.push(&batch).await.unwrap();
assert_eq!(
eviction.peak, 2,
"a batch past the chunk was buffered whole"
);
let staged = eviction.finish().await.unwrap();
assert!(staged.is_some(), "four ids over a chunk of two stage twice");
publish(&view_ds, staged, Vec::new(), None, None)
.await
.unwrap();
native.dataset.reload().await.unwrap();
assert_eq!(read(view.table(), "x").await, vec![5, 6]);
}
#[tokio::test]
async fn test_a_raced_first_rebuild_lands_nothing() {
let _guard = DRIFT_LOCK.lock().await;
let (conn, _) = db_with_source(vec![1, 2, 3]).await;
let view = conn
.create_materialized_view("raced_rebuild", "src")
.with_no_data(true)
.select([("x", "x"), ("twice", "x * 2")])
.execute()
.await
.unwrap();
let native = view.table().as_native().unwrap();
let view_ds = native.dataset.get().await.unwrap().as_ref().clone();
let uri = view_ds.uri().to_string();
*DRIFT_TARGET.lock().unwrap() = Some(uri);
let racing = tokio::spawn(async move { view.refresh().execute().await });
tokio::time::timeout(std::time::Duration::from_secs(30), DRIFT_PLANNED.notified())
.await
.expect("the rebuild never reached the publication boundary");
let batch = record_batch!(
("x", Int32, [8, 9]),
("twice", Int32, [16, 18]),
("__source_row_id", UInt64, [7u64, 8])
)
.unwrap();
let schema = Arc::new(ArrowSchema::from(view_ds.schema()));
let batch = RecordBatch::try_new(
schema.clone(),
schema
.fields()
.iter()
.map(|f| batch.column_by_name(f.name()).unwrap().clone())
.collect(),
)
.unwrap();
let stream: SendableRecordBatchStream = Box::pin(RecordBatchStreamAdapter::new(
schema.clone(),
futures::stream::iter([Ok(batch)]),
));
let keys = Arc::new(StdMutex::new(KeyExistenceFilterBuilder::new(vec![
source_row_id_field_id(&view_ds).unwrap(),
])));
let stream = collect_source_row_ids(stream, keys.clone(), Arc::new(AtomicU64::new(0)));
let write_txn = InsertBuilder::new(WriteDestination::Dataset(Arc::new(view_ds.clone())))
.with_params(&WriteParams {
mode: WriteMode::Append,
..Default::default()
})
.execute_uncommitted_stream(stream)
.await
.unwrap();
let Operation::Append { fragments } = write_txn.operation else {
panic!("expected an append");
};
let filter = {
let mut keys = keys.lock().unwrap();
keys.insert(KeyValue::UInt64(super::REFRESH_TOKEN_ID))
.unwrap();
keys.build()
};
CommitBuilder::new(WriteDestination::Dataset(Arc::new(view_ds.clone())))
.execute(Transaction::new(
view_ds.version().version,
Operation::Update {
removed_fragment_ids: Vec::new(),
updated_fragments: Vec::new(),
new_fragments: fragments,
fields_modified: Vec::new(),
compacted_sstables: Vec::new(),
fields_for_preserving_frag_bitmap: Vec::new(),
update_mode: None,
inserted_rows_filter: Some(filter),
updated_fragment_offsets: None,
},
None,
))
.await
.unwrap();
DRIFT_RELEASED.notify_one();
let err = racing.await.unwrap().unwrap_err();
let raced = conn.open_table("raced_rebuild").execute().await.unwrap();
assert_eq!(
read(&raced, "twice").await,
vec![16, 18],
"the losing rebuild must not union with the winner ({err})"
);
}
#[tokio::test]
async fn test_a_raced_incremental_lands_nothing() {
let _guard = DRIFT_LOCK.lock().await;
let (conn, source) = db_with_source(vec![1, 2, 3]).await;
let view = conn
.create_materialized_view("raced_incremental", "src")
.with_no_data(true)
.select([("x", "x"), ("twice", "x * 2")])
.execute()
.await
.unwrap();
view.refresh().execute().await.unwrap();
append(&source, vec![4]).await;
let native = view.table().as_native().unwrap();
native.dataset.reload().await.unwrap();
let view_ds = native.dataset.get().await.unwrap().as_ref().clone();
*DRIFT_TARGET.lock().unwrap() = Some(view_ds.uri().to_string());
let racing = tokio::spawn(async move { view.refresh().execute().await });
tokio::time::timeout(std::time::Duration::from_secs(30), DRIFT_PLANNED.notified())
.await
.expect("the refresh never reached the publication boundary");
let batch = record_batch!(
("x", Int32, [9]),
("twice", Int32, [18]),
("__source_row_id", UInt64, [8u64])
)
.unwrap();
let schema = Arc::new(ArrowSchema::from(view_ds.schema()));
let batch = RecordBatch::try_new(
schema.clone(),
schema
.fields()
.iter()
.map(|f| batch.column_by_name(f.name()).unwrap().clone())
.collect(),
)
.unwrap();
let stream: SendableRecordBatchStream = Box::pin(RecordBatchStreamAdapter::new(
schema.clone(),
futures::stream::iter([Ok(batch)]),
));
let keys = empty_keys(&view_ds).unwrap();
let stream = collect_source_row_ids(stream, keys.clone(), Arc::new(AtomicU64::new(0)));
let write_txn = InsertBuilder::new(WriteDestination::Dataset(Arc::new(view_ds.clone())))
.with_params(&WriteParams {
mode: WriteMode::Append,
..Default::default()
})
.execute_uncommitted_stream(stream)
.await
.unwrap();
let Operation::Append { fragments } = write_txn.operation else {
panic!("expected an append");
};
let filter = refresh_filter(&keys).unwrap();
CommitBuilder::new(WriteDestination::Dataset(Arc::new(view_ds.clone())))
.execute(Transaction::new(
view_ds.version().version,
Operation::Update {
removed_fragment_ids: Vec::new(),
updated_fragments: Vec::new(),
new_fragments: fragments,
fields_modified: Vec::new(),
compacted_sstables: Vec::new(),
fields_for_preserving_frag_bitmap: Vec::new(),
update_mode: None,
inserted_rows_filter: Some(filter),
updated_fragment_offsets: None,
},
None,
))
.await
.unwrap();
DRIFT_RELEASED.notify_one();
let err = racing.await.unwrap().unwrap_err();
let raced = conn
.open_table("raced_incremental")
.execute()
.await
.unwrap();
assert_eq!(
read(&raced, "twice").await,
vec![2, 4, 6, 18],
"the losing increment must not land its rows ({err})"
);
}
#[tokio::test]
async fn test_oversized_delta_falls_back_to_rebuild() {
let (conn, source) = db_with_source((1..=20).collect()).await;
let view = doubled_view(&conn).await;
view.refresh().execute().await.unwrap();
source.delete("x <= 10").await.unwrap();
*tests::EVICTION_CAP.lock().unwrap() = Some(4);
let result = view.refresh().execute().await;
*tests::EVICTION_CAP.lock().unwrap() = None;
let result = result.unwrap();
assert_eq!(
result.mode,
RefreshMode::Rebuild,
"ten evictions, cap of four"
);
assert_eq!(read(view.table(), "x").await, (11..=20).collect::<Vec<_>>());
source.delete("x = 11").await.unwrap();
let result = view.refresh().execute().await.unwrap();
assert_eq!(result.mode, RefreshMode::Incremental);
assert_eq!(read(view.table(), "x").await, (12..=20).collect::<Vec<_>>());
}
#[tokio::test]
async fn test_zero_limit_holds_no_rows() {
let (conn, source) = db_with_source(vec![1, 2, 3]).await;
let view = conn
.create_materialized_view("empty", "src")
.with_no_data(true)
.select([("x", "x")])
.limit(0)
.execute()
.await
.unwrap();
let result = view.refresh().execute().await.unwrap();
assert_eq!(result.rows_written, 0);
assert_eq!(view.table().count_rows(None).await.unwrap(), 0);
append(&source, vec![4]).await;
view.refresh().execute().await.unwrap();
assert_eq!(view.table().count_rows(None).await.unwrap(), 0);
assert_eq!(
view.refresh().execute().await.unwrap().mode,
RefreshMode::NoOp
);
}
#[tokio::test]
async fn test_update_touching_a_new_fragment_keeps_its_untouched_rows() {
let (_conn, source, view) = refreshed_doubled(vec![1, 2]).await;
append(&source, vec![3, 40]).await;
source
.update()
.column("x", "99")
.only_if("x = 3")
.execute()
.await
.unwrap();
view.refresh().execute().await.unwrap();
assert_eq!(
read(view.table(), "twice").await,
vec![2, 4, 80, 198],
"a row appended into the updated fragment went missing"
);
}
async fn compact(source: &Table) {
source
.optimize(OptimizeAction::Compact {
options: CompactionOptions::default(),
remap_options: None,
})
.await
.unwrap();
}
#[tokio::test]
async fn test_compaction_alone_refreshes_incrementally() {
let (_conn, source, view) = refreshed_doubled(vec![1, 2]).await;
append(&source, vec![3]).await;
view.refresh().execute().await.unwrap();
compact(&source).await;
let result = view.refresh().execute().await.unwrap();
assert_eq!(result.mode, RefreshMode::Incremental);
assert_eq!(result.rows_written, 0);
assert_eq!(read(view.table(), "twice").await, vec![2, 4, 6]);
assert_eq!(
view.refresh().execute().await.unwrap().mode,
RefreshMode::NoOp
);
}
#[tokio::test]
async fn test_append_after_compaction_stays_incremental() {
let (_conn, source, view) = refreshed_doubled(vec![1]).await;
append(&source, vec![2]).await;
view.refresh().execute().await.unwrap();
compact(&source).await;
append(&source, vec![3]).await;
let result = view.refresh().execute().await.unwrap();
assert_eq!(result.mode, RefreshMode::Incremental);
assert_eq!(result.rows_written, 1);
assert_eq!(read(view.table(), "twice").await, vec![2, 4, 6]);
}
#[tokio::test]
async fn test_append_folded_into_compaction_rebuilds() {
let (_conn, source, view) = refreshed_doubled(vec![1]).await;
append(&source, vec![2]).await;
compact(&source).await;
let result = view.refresh().execute().await.unwrap();
assert_eq!(result.mode, RefreshMode::Rebuild);
assert_eq!(read(view.table(), "twice").await, vec![2, 4]);
}
#[tokio::test]
async fn test_refresh_refuses_a_recreated_source_without_stable_row_ids() {
let (conn, _, view) = refreshed_doubled(vec![1]).await;
conn.drop_table("src", &[]).await.unwrap();
let batch = record_batch!(("x", Int32, [9])).unwrap();
conn.create_table("src", batch).execute().await.unwrap();
let err = view.refresh().execute().await.unwrap_err();
assert!(
matches!(err, Error::InvalidInput { message } if message.contains("stable row ids"))
);
}
#[tokio::test]
async fn test_unrelated_column_change_does_not_rebuild() {
let (_conn, source, view) = refreshed_doubled(vec![1, 2]).await;
source
.add_columns()
.transform(NewColumnTransform::AllNulls(Arc::new(ArrowSchema::new(
vec![arrow_schema::Field::new(
"unrelated",
arrow_schema::DataType::Int32,
true,
)],
))))
.execute()
.await
.unwrap();
let result = view.refresh().execute().await.unwrap();
assert_eq!(result.mode, RefreshMode::Incremental);
assert_eq!(result.rows_written, 0);
assert_eq!(read(view.table(), "twice").await, vec![2, 4]);
}
#[tokio::test]
async fn test_full_forces_a_rebuild() {
let (_conn, source, view) = refreshed_doubled(vec![1]).await;
append(&source, vec![2]).await;
let result = view.refresh().full(true).execute().await.unwrap();
assert_eq!(result.mode, RefreshMode::Rebuild);
assert_eq!(result.rows_written, 2);
assert_eq!(read(view.table(), "twice").await, vec![2, 4]);
}
#[tokio::test]
async fn test_limited_view_rebuilds_rather_than_reconcile() {
let (conn, source) = db_with_source(vec![1, 2]).await;
let view = conn
.create_materialized_view("capped", "src")
.with_no_data(true)
.select([("x", "x")])
.limit(2)
.execute()
.await
.unwrap();
view.refresh().execute().await.unwrap();
assert_eq!(read(view.table(), "x").await, vec![1, 2]);
append(&source, vec![3, 4]).await;
source
.update()
.column("x", "11")
.only_if("x = 1")
.execute()
.await
.unwrap();
let result = view.refresh().execute().await.unwrap();
assert_eq!(result.mode, RefreshMode::Rebuild);
let held = read(view.table(), "x").await;
assert_eq!(held.len(), 2, "the cap holds: {held:?}");
let selectable = read(&source, "x").await;
assert!(
held.iter().all(|x| selectable.contains(x)),
"{held:?} is not a subset of {selectable:?}"
);
}
#[tokio::test]
async fn test_limit_caps_the_view() {
let (conn, source) = db_with_source(vec![1, 2, 3]).await;
let view = conn
.create_materialized_view("capped", "src")
.with_no_data(true)
.select([("x", "x")])
.limit(4)
.execute()
.await
.unwrap();
let result = view.refresh().execute().await.unwrap();
assert_eq!(result.rows_written, 3);
append(&source, vec![4, 5, 6]).await;
let result = view.refresh().execute().await.unwrap();
assert_eq!(result.mode, RefreshMode::Incremental);
assert_eq!(result.rows_written, 1);
assert_eq!(view.table().count_rows(None).await.unwrap(), 4);
append(&source, vec![7]).await;
let result = view.refresh().execute().await.unwrap();
assert_eq!(result.rows_written, 0);
assert_eq!(
view.refresh().execute().await.unwrap().mode,
RefreshMode::NoOp
);
}
#[tokio::test]
async fn test_rebuild_retains_indexes() {
let (_conn, source, view) = refreshed_doubled(vec![1, 2, 3]).await;
view.table()
.create_index(&["twice"], Index::BTree(BTreeIndexBuilder::default()))
.execute()
.await
.unwrap();
assert_eq!(view.table().list_indices().await.unwrap().len(), 1);
source
.update()
.column("x", "x + 10")
.execute()
.await
.unwrap();
let result = view.refresh().execute().await.unwrap();
assert_eq!(result.mode, RefreshMode::Rebuild);
assert_eq!(view.table().list_indices().await.unwrap().len(), 1);
assert_eq!(read(view.table(), "twice").await, vec![22, 24, 26]);
let batches = view
.table()
.query()
.only_if("twice = 24")
.execute()
.await
.unwrap()
.try_collect::<Vec<_>>()
.await
.unwrap();
assert_eq!(batches.iter().map(|b| b.num_rows()).sum::<usize>(), 1);
}
#[tokio::test]
async fn test_rebuild_of_an_empty_result_is_an_empty_view() {
let (conn, _) = db_with_source(vec![1, 2]).await;
let view = conn
.create_materialized_view("none", "src")
.with_no_data(true)
.select([("x", "x")])
.only_if("x > 100")
.execute()
.await
.unwrap();
let result = view.refresh().execute().await.unwrap();
assert_eq!(result.mode, RefreshMode::Rebuild);
assert_eq!(result.rows_written, 0);
assert_eq!(view.table().count_rows(None).await.unwrap(), 0);
assert_eq!(
view.refresh().execute().await.unwrap().mode,
RefreshMode::NoOp
);
}
#[tokio::test]
async fn test_source_row_ids_are_recorded() {
let (_conn, _, view) = refreshed_doubled(vec![1, 2, 3]).await;
let batches = view
.table()
.query()
.select(Select::columns(&[SOURCE_ROW_ID_COLUMN]))
.execute()
.await
.unwrap()
.try_collect::<Vec<_>>()
.await
.unwrap();
let total: usize = batches.iter().map(|b| b.num_rows()).sum();
assert_eq!(total, 3);
for batch in &batches {
assert_eq!(batch[SOURCE_ROW_ID_COLUMN].null_count(), 0);
}
}
#[tokio::test]
async fn test_dropping_a_source_input_fails_the_refresh() {
let (conn, source) = db_with_source(vec![1]).await;
let view = conn
.create_materialized_view("v", "src")
.with_no_data(true)
.select([("twice", "x * 2")])
.execute()
.await
.unwrap();
view.refresh().execute().await.unwrap();
append(&source, vec![2]).await;
source
.add_columns()
.transform(NewColumnTransform::AllNulls(Arc::new(ArrowSchema::new(
vec![arrow_schema::Field::new(
"y",
arrow_schema::DataType::Int32,
true,
)],
))))
.execute()
.await
.unwrap();
source.drop_columns(&["x"]).await.unwrap();
let err = view.refresh().execute().await.unwrap_err();
assert!(
matches!(&err, Error::Schema { message }
if message.contains("dropped or renamed") && message.contains("No field named x")),
"{err:?}"
);
}
#[tokio::test]
async fn test_pinned_refresh_and_catch_up() {
let (conn, source) = db_with_source(vec![1]).await;
let view = doubled_view(&conn).await;
let pinned = source.version().await.unwrap();
append(&source, vec![2]).await;
let result = view
.refresh()
.source_version(pinned)
.execute()
.await
.unwrap();
assert_eq!(result.source_version, pinned);
assert_eq!(read(view.table(), "twice").await, vec![2]);
let result = view.refresh().execute().await.unwrap();
assert_eq!(result.mode, RefreshMode::Incremental);
assert_eq!(read(view.table(), "twice").await, vec![2, 4]);
}
#[tokio::test]
async fn test_a_view_can_source_another_view() {
let (conn, source) = db_with_source(vec![1, 2, 30]).await;
let first = doubled_view(&conn).await;
first.refresh().execute().await.unwrap();
let second = conn
.create_materialized_view("second", "doubled")
.with_no_data(true)
.only_if("twice > 10")
.execute()
.await
.unwrap();
assert!(
second
.definition()
.projections
.iter()
.all(|p| p.output != SOURCE_ROW_ID_COLUMN)
);
let result = second.refresh().execute().await.unwrap();
assert_eq!(result.rows_written, 1);
assert_eq!(read(second.table(), "twice").await, vec![60]);
append(&source, vec![50]).await;
first.refresh().execute().await.unwrap();
let result = second.refresh().execute().await.unwrap();
assert_eq!(result.mode, RefreshMode::Incremental);
assert_eq!(read(second.table(), "twice").await, vec![60, 100]);
}
#[tokio::test]
async fn test_direct_view_mutation_forces_a_rebuild() {
let (_conn, _, view) = refreshed_doubled(vec![1, 2]).await;
view.table().delete("x = 1").await.unwrap();
let result = view.refresh().execute().await.unwrap();
assert_eq!(result.mode, RefreshMode::Rebuild);
assert_eq!(read(view.table(), "twice").await, vec![2, 4]);
assert_eq!(
view.refresh().execute().await.unwrap().mode,
RefreshMode::NoOp
);
}
#[tokio::test]
async fn test_source_recreation_forces_a_rebuild() {
let (conn, _, view) = refreshed_doubled(vec![1]).await;
conn.drop_table("src", &[]).await.unwrap();
let batch = record_batch!(("x", Int32, [7])).unwrap();
conn.create_table("src", batch)
.write_options(crate::materialized_view::tests::stable_row_ids())
.execute()
.await
.unwrap();
let result = view.refresh().execute().await.unwrap();
assert_eq!(result.mode, RefreshMode::Rebuild);
assert_eq!(read(view.table(), "twice").await, vec![14]);
}
#[tokio::test]
async fn test_refresh_refuses_a_recreated_view_incarnation() {
let (conn, _, view) = refreshed_doubled(vec![1]).await;
let token = view.incarnation().unwrap().to_string();
view.refresh()
.expect_incarnation(&token)
.execute()
.await
.unwrap();
let reopened = conn.open_materialized_view("doubled").await.unwrap();
assert_eq!(reopened.incarnation(), Some(token.as_str()));
conn.drop_table("doubled", &[]).await.unwrap();
let recreated = doubled_view(&conn).await;
assert_ne!(recreated.incarnation(), Some(token.as_str()));
let err = recreated
.refresh()
.expect_incarnation(&token)
.execute()
.await
.unwrap_err();
assert!(err.to_string().contains("dropped and recreated"), "{err}");
assert_eq!(read(recreated.table(), "twice").await, Vec::<i32>::new());
recreated
.refresh()
.expect_incarnation(recreated.incarnation().unwrap())
.execute()
.await
.unwrap();
assert_eq!(read(recreated.table(), "twice").await, vec![2]);
}
#[tokio::test]
async fn test_cloned_declaration_mints_a_fresh_incarnation_per_create() {
let (conn, source) = db_with_source(vec![1]).await;
let prepared = crate::materialized_view::prepare_declaration(
&source,
Some(&[("x".into(), "x".into()), ("twice".into(), "x * 2".into())]),
None,
None,
)
.await
.unwrap();
let replacement = prepared.clone();
let first = prepared.create("cloned").await.unwrap();
let first_token = first.incarnation().unwrap().to_string();
conn.drop_table("cloned", &[]).await.unwrap();
let second = replacement.create("cloned").await.unwrap();
assert_ne!(second.incarnation(), Some(first_token.as_str()));
}
#[tokio::test(flavor = "multi_thread")]
async fn test_bound_refresh_cannot_publish_into_a_raced_recreation() {
let _serial = DRIFT_LOCK.lock().await;
let (conn, _) = db_with_source(vec![1]).await;
let view = doubled_view(&conn).await;
let token = view.incarnation().unwrap().to_string();
let uri = view
.table()
.as_native()
.unwrap()
.dataset
.get()
.await
.unwrap()
.uri()
.to_string();
*DRIFT_TARGET.lock().unwrap() = Some(uri);
let refreshing =
tokio::spawn(async move { view.refresh().expect_incarnation(token).execute().await });
tokio::time::timeout(std::time::Duration::from_secs(30), DRIFT_PLANNED.notified())
.await
.expect("refresh never reached publication");
conn.drop_table("doubled", &[]).await.unwrap();
let replacement = doubled_view(&conn).await;
let replacement_token = replacement.incarnation().unwrap().to_string();
DRIFT_RELEASED.notify_one();
let result = refreshing.await.unwrap();
assert!(result.is_err(), "the stale refresh unexpectedly succeeded");
let reopened = conn.open_materialized_view("doubled").await.unwrap();
assert_eq!(reopened.incarnation(), Some(replacement_token.as_str()));
assert_eq!(read(reopened.table(), "twice").await, Vec::<i32>::new());
}
#[tokio::test]
async fn test_a_view_whose_metadata_was_replaced_starts_a_new_incarnation() {
let (conn, _, view) = refreshed_doubled(vec![1]).await;
let token = view.incarnation().unwrap().to_string();
let mut metadata = HashMap::new();
metadata.insert(
crate::materialized_view::DEFINITION_META_KEY.to_string(),
crate::materialized_view::definition_to_metadata(view.definition()).unwrap(),
);
view.table()
.as_native()
.unwrap()
.replace_schema_metadata(metadata)
.await
.unwrap();
assert_eq!(
conn.open_materialized_view("doubled")
.await
.unwrap()
.incarnation(),
None
);
let err = view
.refresh()
.expect_incarnation(&token)
.execute()
.await
.unwrap_err();
assert!(err.to_string().contains("no incarnation token"), "{err}");
view.refresh().execute().await.unwrap();
let reopened = conn.open_materialized_view("doubled").await.unwrap();
assert!(reopened.incarnation().is_some());
assert_ne!(reopened.incarnation(), Some(token.as_str()));
}
#[tokio::test(flavor = "multi_thread")]
async fn test_concurrent_refreshes_do_not_duplicate() {
let (_conn, source, view) = refreshed_doubled(vec![1]).await;
append(&source, vec![2, 3]).await;
let (a, b) = tokio::join!(view.refresh().execute(), view.refresh().execute());
let (a, b) = (a.unwrap(), b.unwrap());
assert_eq!(read(view.table(), "twice").await, vec![2, 4, 6]);
let modes = [a.mode, b.mode];
assert!(modes.contains(&RefreshMode::Incremental));
assert!(modes.contains(&RefreshMode::NoOp));
}
#[tokio::test]
async fn test_a_second_handle_does_not_double_append() {
let (conn, source, view) = refreshed_doubled(vec![1, 2, 3]).await;
let stale = conn.open_materialized_view("doubled").await.unwrap();
append(&source, vec![4]).await;
view.refresh().execute().await.unwrap();
let result = stale.refresh().execute().await.unwrap();
assert_eq!(result.mode, RefreshMode::NoOp);
assert_eq!(read(view.table(), "twice").await, vec![2, 4, 6, 8]);
}
#[tokio::test]
async fn test_stamp_aborts_on_a_racing_commit() {
let (_conn, _, view) = refreshed_doubled(vec![1, 2]).await;
let view_native = view.table().as_native().unwrap();
let stale = view_native.dataset.get().await.unwrap().as_ref().clone();
view.table().delete("x = 1").await.unwrap();
let err = stamp_watermark(view_native, stale, 99, 99, None, None).await;
assert!(err.is_err());
let result = view.refresh().execute().await.unwrap();
assert_eq!(result.mode, RefreshMode::Rebuild);
assert_eq!(read(view.table(), "twice").await, vec![2, 4]);
}
#[tokio::test]
async fn test_refresh_uses_the_latest_persisted_definition() {
let (_conn, _, view) = refreshed_doubled(vec![1, 2]).await;
let replacement = crate::materialized_view::MaterializedViewDefinition {
vector_source: None,
source_table: "src".into(),
source_namespace: Vec::new(),
projections: vec![
crate::materialized_view::ViewProjection {
output: "x".into(),
expression: "x".into(),
},
crate::materialized_view::ViewProjection {
output: "twice".into(),
expression: "x * 3".into(),
},
],
filter: None,
group_by: Vec::new(),
limit: None,
lateral: None,
};
let mut metadata = HashMap::new();
metadata.insert(
crate::materialized_view::DEFINITION_META_KEY.to_string(),
crate::materialized_view::definition_to_metadata(&replacement).unwrap(),
);
view.table()
.as_native()
.unwrap()
.replace_schema_metadata(metadata)
.await
.unwrap();
let result = view.refresh().execute().await.unwrap();
assert_eq!(result.mode, RefreshMode::Rebuild);
assert_eq!(read(view.table(), "twice").await, vec![3, 6]);
}
#[tokio::test]
async fn test_definition_view_schema_mismatch_is_refused() {
let (_conn, _, view) = refreshed_doubled(vec![1, 2]).await;
let narrower = crate::materialized_view::MaterializedViewDefinition {
vector_source: None,
source_table: "src".into(),
source_namespace: Vec::new(),
projections: vec![crate::materialized_view::ViewProjection {
output: "x".into(),
expression: "x".into(),
}],
filter: None,
group_by: Vec::new(),
limit: None,
lateral: None,
};
let mut metadata = HashMap::new();
metadata.insert(
crate::materialized_view::DEFINITION_META_KEY.to_string(),
crate::materialized_view::definition_to_metadata(&narrower).unwrap(),
);
view.table()
.as_native()
.unwrap()
.replace_schema_metadata(metadata)
.await
.unwrap();
let err = view.refresh().execute().await.unwrap_err();
assert!(matches!(err, Error::Schema { message } if message.contains("does not produce")),);
}
#[test]
fn test_fragment_signature_sees_overlays() {
use lance_file::version::ConcreteFileVersion;
use lance_table::format::DataFile;
use lance_table::format::overlay::{DataOverlayFile, OverlayCoverage};
let base = Fragment::new(7);
let mut file = DataFile::new_unstarted("f0.lance", ConcreteFileVersion::V2_1);
file.fields = vec![0, 1].into();
let mut with_file = base.clone();
with_file.files.push(file.clone());
let overlay = |field: i32| {
let mut data_file = DataFile::new_unstarted("o0.lance", ConcreteFileVersion::V2_1);
data_file.fields = vec![field].into();
DataOverlayFile {
data_file,
coverage: OverlayCoverage::PerField(Vec::new()),
committed_version: 9,
}
};
let relevant: HashSet<i32> = [0].into_iter().collect();
let mut overlaid_relevant = with_file.clone();
overlaid_relevant.overlays.push(overlay(0));
assert_ne!(
fragment_signature(&with_file, &relevant),
fragment_signature(&overlaid_relevant, &relevant),
);
let mut overlaid_unrelated = with_file.clone();
overlaid_unrelated.overlays.push(overlay(5));
assert_eq!(
fragment_signature(&with_file, &relevant),
fragment_signature(&overlaid_unrelated, &relevant),
);
}
#[tokio::test]
async fn test_output_names_needing_quotes() {
let (conn, _) = db_with_source(vec![1, 2]).await;
let view = conn
.create_materialized_view("v", "src")
.with_no_data(true)
.select([("double value", "x * 2")])
.execute()
.await
.unwrap();
let result = view.refresh().execute().await.unwrap();
assert_eq!(result.rows_written, 2);
assert_eq!(read(view.table(), "double value").await, vec![2, 4]);
}
#[tokio::test]
async fn lsm_state_disqualifies_source_and_view() {
use crate::table::LsmWriteSpec;
use arrow_array::RecordBatchIterator;
let tmp_dir = tempfile::tempdir().unwrap();
let conn = connect(tmp_dir.path().to_str().unwrap())
.execute()
.await
.unwrap();
let schema = Arc::new(ArrowSchema::new(vec![
arrow_schema::Field::new("id", arrow_schema::DataType::Int64, false),
arrow_schema::Field::new("x", arrow_schema::DataType::Int32, false),
]));
let batch = RecordBatch::try_new(
schema.clone(),
vec![
Arc::new(arrow_array::Int64Array::from(vec![1, 2])) as _,
Arc::new(Int32Array::from(vec![1, 2])) as _,
],
)
.unwrap();
let table = conn
.create_table("src", batch.clone())
.write_options(crate::materialized_view::tests::stable_row_ids())
.execute()
.await
.unwrap();
table.set_unenforced_primary_key(["id"]).await.unwrap();
table
.set_lsm_write_spec(LsmWriteSpec::unsharded())
.await
.unwrap();
let err = conn
.create_materialized_view("v", "src")
.with_no_data(true)
.execute()
.await
.unwrap_err();
assert!(err.to_string().contains("un-compacted"), "{err}");
table.unset_lsm_write_spec().await.unwrap();
let view = conn
.create_materialized_view("v", "src")
.with_no_data(true)
.execute()
.await
.unwrap();
let err = view
.table()
.set_lsm_write_spec(LsmWriteSpec::unsharded())
.await
.unwrap_err();
assert!(err.to_string().contains("materialized view"), "{err}");
table
.set_lsm_write_spec(LsmWriteSpec::unsharded())
.await
.unwrap();
let err = view.refresh().execute().await.unwrap_err();
assert!(err.to_string().contains("source table 'src'"), "{err}");
let mut merge = table.merge_insert(&["id"]);
merge
.when_matched_update_all(None)
.when_not_matched_insert_all()
.use_lsm(true);
merge
.execute(Box::new(RecordBatchIterator::new(
vec![Ok(batch.clone())],
batch.schema(),
)))
.await
.unwrap();
table.unset_lsm_write_spec().await.unwrap();
let err = view.refresh().execute().await.unwrap_err();
assert!(err.to_string().contains("source table 'src'"), "{err}");
}
async fn refreshed_computed_view(conn: &Connection) -> MaterializedView {
use crate::materialized_view::tests::{computed_field, people, test_binding};
let source = people(conn).await;
let view = crate::materialized_view::prepare_declaration(
&source,
Some(&[
("id".to_string(), "id".to_string()),
("name".to_string(), "name".to_string()),
]),
None,
None,
)
.await
.unwrap()
.with_computed_columns(
vec![(2, computed_field("emb", "fb_1", "name"))],
&[test_binding("fb_1", "name", "emb")],
)
.unwrap()
.create("v")
.await
.unwrap();
let result = view.refresh().execute().await.unwrap();
assert_eq!(result.mode, RefreshMode::Rebuild);
view
}
async fn unfilled(view: &MaterializedView) -> usize {
view.table()
.count_rows(Some("emb IS NULL".to_string()))
.await
.unwrap()
}
async fn append_people(conn: &Connection, ids: Vec<i32>, names: Vec<&str>) {
let batch = record_batch!(("id", Int32, ids), ("name", Utf8, names)).unwrap();
conn.open_table("people")
.execute()
.await
.unwrap()
.add(batch)
.execute()
.await
.unwrap();
}
async fn commit_column_rewrite(view: &MaterializedView, fields: &[&str]) {
let native = view.table().as_native().unwrap();
native.dataset.reload().await.unwrap();
let dataset = native.dataset.get().await.unwrap().as_ref().clone();
let fields_modified = fields
.iter()
.map(|name| dataset.schema().field(name).unwrap().id as u32)
.collect();
let updated_fragments = dataset
.get_fragments()
.iter()
.map(|fragment| fragment.metadata().clone())
.collect();
let operation = Operation::Update {
removed_fragment_ids: Vec::new(),
updated_fragments,
new_fragments: Vec::new(),
fields_modified,
compacted_sstables: Vec::new(),
fields_for_preserving_frag_bitmap: Vec::new(),
update_mode: Some(UpdateMode::RewriteColumns),
inserted_rows_filter: None,
updated_fragment_offsets: None,
};
let read_version = dataset.version().version;
CommitBuilder::new(WriteDestination::Dataset(Arc::new(dataset)))
.execute(Transaction::new(read_version, operation, None))
.await
.unwrap();
}
#[tokio::test]
async fn test_computed_columns_are_written_null_and_kept() {
let conn = connect("memory://").execute().await.unwrap();
let view = refreshed_computed_view(&conn).await;
assert_eq!(unfilled(&view).await, 3);
append_people(&conn, vec![4, 5], vec!["d", "e"]).await;
let result = view.refresh().execute().await.unwrap();
assert_eq!(result.mode, RefreshMode::Incremental);
assert_eq!(unfilled(&view).await, 5);
conn.open_table("people")
.execute()
.await
.unwrap()
.update()
.column("name", "'z'")
.only_if("id = 1")
.execute()
.await
.unwrap();
view.refresh().execute().await.unwrap();
assert_eq!(unfilled(&view).await, 5);
assert_eq!(read(view.table(), "id").await, vec![1, 2, 3, 4, 5]);
let schema = view.table().schema().await.unwrap();
assert!(
crate::table::computed_columns::function_bindings(&schema)
.unwrap()
.iter()
.any(|b| b.binding_id() == "fb_1"),
"the binding envelope was lost"
);
assert!(
computed_column_from_field(schema.field_with_name("emb").unwrap()).is_some(),
"the declaration was lost"
);
assert_eq!(
view.refresh().execute().await.unwrap().mode,
RefreshMode::NoOp
);
}
async fn commit_field_metadata(view: &MaterializedView, field: &str, key: &str) {
let native = view.table().as_native().unwrap();
native.dataset.reload().await.unwrap();
let mut dataset = native.dataset.get().await.unwrap().as_ref().clone();
dataset
.update_field_metadata()
.update(field, [(key.to_string(), "{}".to_string())])
.unwrap()
.await
.unwrap();
}
#[tokio::test]
async fn test_a_freshness_stamp_is_not_drift() {
let conn = connect("memory://").execute().await.unwrap();
let view = refreshed_computed_view(&conn).await;
commit_field_metadata(
&view,
"emb",
crate::table::computed_columns::SOURCE_SIGNATURE_META_KEY,
)
.await;
assert_eq!(
view.refresh().execute().await.unwrap().mode,
RefreshMode::NoOp
);
commit_field_metadata(&view, "id", "probe").await;
assert_eq!(
view.refresh().execute().await.unwrap().mode,
RefreshMode::Rebuild
);
}
#[tokio::test]
async fn test_a_computed_column_fill_is_not_drift() {
let conn = connect("memory://").execute().await.unwrap();
let view = refreshed_computed_view(&conn).await;
commit_column_rewrite(&view, &["emb"]).await;
assert_eq!(
view.refresh().execute().await.unwrap().mode,
RefreshMode::NoOp
);
commit_column_rewrite(&view, &["emb"]).await;
append_people(&conn, vec![4], vec!["d"]).await;
let result = view.refresh().execute().await.unwrap();
assert_eq!(result.mode, RefreshMode::Incremental);
assert_eq!(result.rows_written, 1);
assert_eq!(read(view.table(), "id").await, vec![1, 2, 3, 4]);
}
#[tokio::test]
async fn test_a_rewrite_of_a_projected_column_is_drift() {
let conn = connect("memory://").execute().await.unwrap();
let view = refreshed_computed_view(&conn).await;
commit_column_rewrite(&view, &["emb", "name"]).await;
assert_eq!(
view.refresh().execute().await.unwrap().mode,
RefreshMode::Rebuild
);
}
#[tokio::test]
async fn test_a_broken_declaration_is_refused_before_refresh() {
let conn = connect("memory://").execute().await.unwrap();
let view = refreshed_computed_view(&conn).await;
let native = view.table().as_native().unwrap();
let mut dataset = native.dataset.get().await.unwrap().as_ref().clone();
dataset
.update_schema_metadata(vec![(
crate::table::computed_columns::FUNCTION_BINDINGS_META_KEY.to_string(),
None,
)])
.await
.unwrap();
let err = view.refresh().execute().await.unwrap_err().to_string();
assert!(err.contains("references missing binding 'fb_1'"), "{err}");
let conn = connect("memory://").execute().await.unwrap();
let view = refreshed_computed_view(&conn).await;
let native = view.table().as_native().unwrap();
let mut dataset = native.dataset.get().await.unwrap().as_ref().clone();
dataset
.replace_field_metadata(vec![(
dataset.schema().field("emb").unwrap().id as u32,
HashMap::new(),
)])
.await
.unwrap();
let err = view.refresh().execute().await.unwrap_err().to_string();
assert!(err.contains("does not match binding 'fb_1'"), "{err}");
}
#[tokio::test]
async fn test_internal_inputs_are_materialized_and_refreshed() {
use crate::materialized_view::tests::{computed_field, strict_people, test_binding};
let conn = connect("memory://").execute().await.unwrap();
let source = strict_people(&conn).await;
let mut prepared = crate::materialized_view::prepare_declaration(
&source,
Some(&[("id".to_string(), "id".to_string())]),
None,
None,
)
.await
.unwrap();
let input = prepared.input_column("name").unwrap();
let view = prepared
.with_computed_columns(
vec![(1, computed_field("emb", "fb_1", &input))],
&[test_binding("fb_1", &input, "emb")],
)
.unwrap()
.create("v")
.await
.unwrap();
let names: Vec<String> = view
.table()
.schema()
.await
.unwrap()
.fields()
.iter()
.map(|f| f.name().clone())
.collect();
assert_eq!(names, ["id", "emb", "__input_name", SOURCE_ROW_ID_COLUMN]);
let unfilled_inputs = || async {
view.table()
.count_rows(Some("__input_name IS NULL".to_string()))
.await
.unwrap()
};
assert_eq!(
view.refresh().execute().await.unwrap().mode,
RefreshMode::Rebuild
);
assert_eq!(view.table().count_rows(None).await.unwrap(), 3);
assert_eq!(unfilled_inputs().await, 0);
let more = arrow_array::RecordBatch::try_new(
source.schema().await.unwrap(),
vec![
Arc::new(Int32Array::from(vec![4])),
Arc::new(arrow_array::StringArray::from(vec!["d"])),
],
)
.unwrap();
source.add(more).execute().await.unwrap();
assert_eq!(
view.refresh().execute().await.unwrap().mode,
RefreshMode::Incremental
);
assert_eq!(unfilled_inputs().await, 0);
assert_eq!(
view.table()
.count_rows(Some("__input_name = 'd'".to_string()))
.await
.unwrap(),
1
);
source
.update()
.column("name", "'z'")
.only_if("id = 1")
.execute()
.await
.unwrap();
view.refresh().execute().await.unwrap();
assert_eq!(
view.table()
.count_rows(Some("__input_name = 'z'".to_string()))
.await
.unwrap(),
1
);
assert_eq!(
unfilled(&view).await,
4,
"rewritten and new rows are unfilled"
);
}
#[tokio::test]
async fn test_a_sql_fill_is_not_drift() {
use crate::materialized_view::tests::{people, sql_field};
let conn = connect("memory://").execute().await.unwrap();
let source = people(&conn).await;
let view = crate::materialized_view::prepare_declaration(
&source,
Some(&[("id".to_string(), "id".to_string())]),
None,
None,
)
.await
.unwrap()
.with_computed_columns(
vec![(
1,
sql_field("next", arrow_schema::DataType::Int32, "id + 1", r#"["id"]"#),
)],
&[],
)
.unwrap()
.create("v")
.await
.unwrap();
let filled = || async {
view.table()
.count_rows(Some("next = id + 1".to_string()))
.await
.unwrap()
};
assert_eq!(
view.refresh().execute().await.unwrap().mode,
RefreshMode::Rebuild
);
assert_eq!(
view.table()
.refresh_column("next")
.await
.unwrap()
.rows_filled,
3
);
assert_eq!(filled().await, 3);
assert_eq!(
view.refresh().execute().await.unwrap().mode,
RefreshMode::NoOp
);
assert_eq!(filled().await, 3);
append_people(&conn, vec![4], vec!["d"]).await;
assert_eq!(
view.refresh().execute().await.unwrap().mode,
RefreshMode::Incremental
);
assert_eq!(filled().await, 3);
assert_eq!(
view.table()
.refresh_column("next")
.await
.unwrap()
.rows_filled,
1
);
assert_eq!(filled().await, 4);
assert_eq!(
view.refresh().execute().await.unwrap().mode,
RefreshMode::NoOp
);
}
#[tokio::test]
async fn test_a_nested_sql_fill_is_not_drift() {
use crate::materialized_view::tests::{people, sql_field};
let conn = connect("memory://").execute().await.unwrap();
let source = people(&conn).await;
let payload = sql_field(
"payload",
arrow_schema::DataType::Struct(
vec![arrow_schema::Field::new(
"value",
arrow_schema::DataType::Utf8,
true,
)]
.into(),
),
"named_struct('value', name)",
r#"["name"]"#,
);
let view = crate::materialized_view::prepare_declaration(
&source,
Some(&[("name".to_string(), "name".to_string())]),
None,
None,
)
.await
.unwrap()
.with_computed_columns(vec![(1, payload)], &[])
.unwrap()
.create("v")
.await
.unwrap();
view.refresh().execute().await.unwrap();
assert_eq!(
view.table()
.refresh_column("payload")
.await
.unwrap()
.rows_filled,
3
);
assert_eq!(
view.refresh().execute().await.unwrap().mode,
RefreshMode::NoOp
);
assert_eq!(
view.table()
.count_rows(Some("payload.value = name".to_string()))
.await
.unwrap(),
3
);
}
async fn grouped_source(conn: &Connection) -> Table {
conn.create_table(
"src",
record_batch!(("k", Int32, [1, 1, 2, 3]), ("x", Int32, [10, 11, 20, 30])).unwrap(),
)
.write_options(crate::materialized_view::tests::stable_row_ids())
.execute()
.await
.unwrap()
}
pub(in crate::materialized_view) async fn declare(
source: &Table,
name: &str,
sql: &str,
) -> Result<MaterializedView> {
crate::materialized_view::prepare_definition(
source,
MaterializedViewDefinition::from_sql(sql)?,
)
.await?
.create(name)
.await
}
pub(in crate::materialized_view) async fn rows(table: &Table, columns: &[&str]) -> Vec<String> {
let batches = table
.query()
.select(Select::columns(columns))
.execute()
.await
.unwrap()
.try_collect::<Vec<_>>()
.await
.unwrap();
let mut rows = Vec::new();
for batch in &batches {
let formatters: Vec<_> = batch
.columns()
.iter()
.map(|c| {
arrow_cast::display::ArrayFormatter::try_new(c.as_ref(), &Default::default())
.unwrap()
})
.collect();
for row in 0..batch.num_rows() {
let cells: Vec<String> = formatters
.iter()
.map(|f| f.value(row).to_string())
.collect();
rows.push(cells.join(" "));
}
}
rows.sort();
rows
}
#[tokio::test]
async fn a_grouped_view_holds_one_row_per_group() {
let conn = connect("memory://").execute().await.unwrap();
let source = grouped_source(&conn).await;
let view = declare(
&source,
"groups",
"SELECT k, array_agg(x) AS xs, sum(x) AS total FROM src WHERE x < 30 GROUP BY k",
)
.await
.unwrap();
assert_eq!(
view.definition().to_sql(),
"SELECT k, array_agg(x) AS xs, sum(x) AS total FROM src WHERE x < 30 GROUP BY k"
);
let result = view.refresh().execute().await.unwrap();
assert_eq!(result.mode, RefreshMode::Rebuild);
assert_eq!(result.rows_written, 2);
assert_eq!(rows(view.table(), &["k", "total"]).await, ["1 21", "2 20"]);
let unchanged = view.refresh().execute().await.unwrap();
assert_eq!(unchanged.mode, RefreshMode::NoOp);
source
.add(record_batch!(("k", Int32, [2]), ("x", Int32, [5])).unwrap())
.execute()
.await
.unwrap();
let result = view.refresh().execute().await.unwrap();
assert_eq!(result.mode, RefreshMode::Rebuild);
assert_eq!(rows(view.table(), &["k", "total"]).await, ["1 21", "2 25"]);
let expanded = declare(
view.table(),
"elements",
"SELECT k, e AS x FROM groups, UNNEST(xs) AS e",
)
.await
.unwrap();
expanded.refresh().execute().await.unwrap();
assert_eq!(
rows(expanded.table(), &["k", "x"]).await,
["1 10", "1 11", "2 20", "2 5"]
);
}
#[tokio::test]
async fn a_grouped_view_records_one_source_row_per_group() {
let conn = connect("memory://").execute().await.unwrap();
let source = grouped_source(&conn).await;
let view = declare(
&source,
"groups",
"SELECT k, count(*) AS n FROM src GROUP BY k",
)
.await
.unwrap();
view.refresh().execute().await.unwrap();
let ids = rows(view.table(), &[SOURCE_ROW_ID_COLUMN]).await;
assert_eq!(ids.len(), 3);
assert_eq!(ids.iter().collect::<HashSet<_>>().len(), 3, "{ids:?}");
}
#[tokio::test]
async fn a_grouped_view_is_planned_at_declaration() {
let conn = connect("memory://").execute().await.unwrap();
let source = grouped_source(&conn).await;
for sql in [
"SELECT k, x FROM src GROUP BY k",
"SELECT k, random() AS r FROM src GROUP BY k",
"SELECT k, count(*) AS n FROM src GROUP BY missing",
"SELECT k, count(*) AS __source_row_id FROM src GROUP BY k",
"SELECT k, count(*) AS k FROM src GROUP BY k",
] {
assert!(declare(&source, "v", sql).await.is_err(), "{sql}");
}
let mut grouped = crate::materialized_view::prepare_definition(
&source,
MaterializedViewDefinition::from_sql("SELECT k, count(*) AS n FROM src GROUP BY k")
.unwrap(),
)
.await
.unwrap();
assert!(grouped.input_column("x").is_err());
}
pub(in crate::materialized_view) async fn clustered_source(conn: &Connection) -> Table {
use arrow_array::types::Float32Type;
use arrow_array::{FixedSizeListArray, Int32Array};
let vectors = FixedSizeListArray::from_iter_primitive::<Float32Type, _, _>(
(0..20).map(|i| {
let base = if i < 10 { 0.0 } else { 100.0 };
Some(vec![Some(base + 1.0 + i as f32 * 0.1), Some(base)])
}),
2,
);
let batch = RecordBatch::try_from_iter(vec![
(
"id",
Arc::new(Int32Array::from_iter_values(0..20)) as arrow_array::ArrayRef,
),
("vec", Arc::new(vectors) as arrow_array::ArrayRef),
])
.unwrap();
conn.create_table("images", batch)
.write_options(crate::materialized_view::tests::stable_row_ids())
.execute()
.await
.unwrap()
}
#[tokio::test]
async fn ivf_partition_groups_by_the_index_on_the_column() {
use crate::index::vector::IvfFlatIndexBuilder;
const BUCKETS: &str = "SELECT ivf_partition(vec) AS bucket, count(*) AS n, \
min(id) AS lo, max(id) AS hi FROM images GROUP BY ivf_partition(vec)";
let conn = connect("memory://").execute().await.unwrap();
let source = clustered_source(&conn).await;
let unindexed = declare(&source, "buckets", BUCKETS).await.unwrap_err();
assert!(
unindexed.to_string().contains("IVF vector index"),
"{unindexed}"
);
source
.create_index(
&["vec"],
Index::IvfFlat(IvfFlatIndexBuilder::default().num_partitions(2)),
)
.execute()
.await
.unwrap();
let view = declare(&source, "buckets", BUCKETS).await.unwrap();
view.refresh().execute().await.unwrap();
assert_eq!(
rows(view.table(), &["n", "lo", "hi"]).await,
["10 0 9", "10 10 19"]
);
let buckets = rows(view.table(), &["bucket"]).await;
assert_eq!(buckets, ["0", "1"]);
}
#[tokio::test]
async fn ivf_partition_takes_a_column() {
let conn = connect("memory://").execute().await.unwrap();
let source = clustered_source(&conn).await;
for sql in [
"SELECT ivf_partition(id) AS b, count(*) AS n FROM images GROUP BY ivf_partition(id)",
"SELECT ivf_partition(vec, vec) AS b, count(*) AS n FROM images \
GROUP BY ivf_partition(vec, vec)",
] {
assert!(declare(&source, "v", sql).await.is_err(), "{sql}");
}
}
#[tokio::test]
async fn ivf_partition_supports_cosine_indices() {
use crate::index::vector::IvfFlatIndexBuilder;
const BUCKETS: &str = "SELECT ivf_partition(vec) AS bucket, count(*) AS n, \
min(id) AS lo, max(id) AS hi FROM images GROUP BY ivf_partition(vec)";
let conn = connect("memory://").execute().await.unwrap();
let source = clustered_source(&conn).await;
source
.create_index(
&["vec"],
Index::IvfFlat(
IvfFlatIndexBuilder::default()
.num_partitions(2)
.distance_type(crate::DistanceType::Cosine),
),
)
.execute()
.await
.unwrap();
let view = declare(&source, "cosine_buckets", BUCKETS).await.unwrap();
view.refresh().execute().await.unwrap();
assert_eq!(
rows(view.table(), &["n", "lo", "hi"]).await,
["10 0 9", "10 10 19"]
);
}
#[tokio::test]
async fn ivf_partition_refuses_segments_with_different_models() {
use arrow_array::types::Float32Type;
use arrow_array::{ArrayRef, FixedSizeListArray, Int32Array};
use lance::index::DatasetIndexExt;
use lance::index::vector::VectorIndexParams;
use lance_index::IndexType;
let conn = connect("memory://").execute().await.unwrap();
let source = clustered_source(&conn).await;
let far = FixedSizeListArray::from_iter_primitive::<Float32Type, _, _>(
(0..20).map(|i| Some(vec![Some(-500.0 - i as f32), Some(-500.0)])),
2,
);
source
.add(
RecordBatch::try_from_iter(vec![
(
"id",
Arc::new(Int32Array::from_iter_values(20..40)) as ArrayRef,
),
("vec", Arc::new(far) as ArrayRef),
])
.unwrap(),
)
.execute()
.await
.unwrap();
let dataset = source.as_native().unwrap().dataset.get().await.unwrap();
let mut dataset = dataset.as_ref().clone();
let params = VectorIndexParams::ivf_flat(2, lance_linalg::distance::DistanceType::L2);
let mut segments = Vec::new();
for fragment in dataset.get_fragments() {
let segment = dataset
.create_index_builder(&["vec"], IndexType::Vector, ¶ms)
.name("shared".to_string())
.fragments(vec![fragment.id() as u32])
.execute_uncommitted()
.await
.unwrap();
segments.push(segment);
}
dataset
.commit_existing_index_segments("shared", "vec", segments)
.await
.unwrap();
assert_eq!(dataset.load_indices().await.unwrap().len(), 2);
let source = conn.open_table("images").execute().await.unwrap();
let error = declare(
&source,
"buckets",
"SELECT ivf_partition(vec) AS b, count(*) AS n FROM images GROUP BY ivf_partition(vec)",
)
.await
.unwrap_err();
assert!(error.to_string().contains("trained separately"), "{error}");
}
#[tokio::test]
async fn ivf_partition_leaves_an_unassignable_vector_unbucketed() {
use crate::index::vector::IvfFlatIndexBuilder;
use arrow_array::types::Float32Type;
use arrow_array::{ArrayRef, FixedSizeListArray, Int32Array};
let conn = connect("memory://").execute().await.unwrap();
let source = clustered_source(&conn).await;
source
.create_index(
&["vec"],
Index::IvfFlat(
IvfFlatIndexBuilder::default()
.num_partitions(2)
.distance_type(crate::DistanceType::Cosine),
),
)
.execute()
.await
.unwrap();
let zero = FixedSizeListArray::from_iter_primitive::<Float32Type, _, _>(
[Some(vec![Some(0.0), Some(0.0)])],
2,
);
source
.add(
RecordBatch::try_from_iter(vec![
("id", Arc::new(Int32Array::from(vec![20])) as ArrayRef),
("vec", Arc::new(zero) as ArrayRef),
])
.unwrap(),
)
.execute()
.await
.unwrap();
let view = declare(
&source,
"buckets",
"SELECT ivf_partition(vec) AS bucket, count(*) AS n, min(id) AS lo, max(id) AS hi \
FROM images GROUP BY ivf_partition(vec)",
)
.await
.unwrap();
view.refresh().execute().await.unwrap();
let groups = rows(view.table(), &["bucket", "n", "lo", "hi"]).await;
let (unbucketed, bucketed): (Vec<_>, Vec<_>) =
groups.iter().partition(|row| row.starts_with(' '));
assert_eq!(unbucketed, [" 1 20 20"], "{groups:?}");
let mut clusters: Vec<&str> = bucketed
.iter()
.map(|row| row.split_once(' ').unwrap().1)
.collect();
clusters.sort();
assert_eq!(clusters, ["10 0 9", "10 10 19"], "{groups:?}");
}
#[tokio::test]
async fn ivf_partition_groups_byte_vectors_by_hamming() {
use arrow_array::types::UInt8Type;
use arrow_array::{ArrayRef, FixedSizeListArray, Int32Array};
use lance::index::DatasetIndexExt;
use lance::index::vector::VectorIndexParams;
use lance_index::IndexType;
use lance_index::vector::ivf::IvfBuildParams;
let hashes = FixedSizeListArray::from_iter_primitive::<UInt8Type, _, _>(
(0..20u8).map(|i| {
let base = if i < 10 { 0x00 } else { 0xff };
Some(vec![
Some(base ^ (i % 4)),
Some(base),
Some(base),
Some(base),
])
}),
4,
);
let batch = RecordBatch::try_from_iter(vec![
(
"id",
Arc::new(Int32Array::from_iter_values(0..20)) as ArrayRef,
),
("phash", Arc::new(hashes) as ArrayRef),
])
.unwrap();
let conn = connect("memory://").execute().await.unwrap();
let source = conn
.create_table("images", batch)
.write_options(crate::materialized_view::tests::stable_row_ids())
.execute()
.await
.unwrap();
let centroids = FixedSizeListArray::from_iter_primitive::<UInt8Type, _, _>(
[Some(vec![Some(0); 4]), Some(vec![Some(0xff); 4])],
4,
);
let params = VectorIndexParams::with_ivf_flat_params(
lance_linalg::distance::DistanceType::Hamming,
IvfBuildParams::try_with_centroids(2, Arc::new(centroids)).unwrap(),
);
let mut dataset = source
.as_native()
.unwrap()
.dataset
.get()
.await
.unwrap()
.as_ref()
.clone();
dataset
.create_index(&["phash"], IndexType::Vector, None, ¶ms, true)
.await
.unwrap();
let source = conn.open_table("images").execute().await.unwrap();
const BUCKETS: &str = "SELECT ivf_partition(phash) AS bucket, count(*) AS n, \
min(id) AS lo, max(id) AS hi FROM images GROUP BY ivf_partition(phash)";
let view = declare(&source, "buckets", BUCKETS).await.unwrap();
view.refresh().execute().await.unwrap();
assert_eq!(
rows(view.table(), &["bucket", "n", "lo", "hi"]).await,
["0 10 0 9", "1 10 10 19"]
);
}
}