use std::collections::HashMap;
use std::sync::Arc;
use std::sync::atomic::{AtomicU64, Ordering};
use arrow_array::Array;
use arrow_array::RecordBatch;
use arrow_array::cast::AsArray;
use arrow_array::types::UInt64Type;
use arrow_schema::{Schema as ArrowSchema, SchemaRef};
use datafusion::catalog::default_table_source::provider_as_source;
use datafusion::physical_plan::SendableRecordBatchStream;
use datafusion::physical_plan::stream::RecordBatchStreamAdapter;
use datafusion::prelude::SessionContext;
use futures::{StreamExt, TryStreamExt};
use lance::Dataset;
use lance::dataset::transaction::{Operation, Transaction};
use lance::dataset::{
CommitBuilder, InsertBuilder, ProjectionRequest, WriteDestination, WriteMode, WriteParams,
};
use lance_core::ROW_ID;
use lance_core::utils::address::RowAddress;
use lance_datafusion::exec::SessionContextExt;
use lance_index::metrics::NoOpMetricsCollector;
use lance_table::format::Fragment;
use serde::{Deserialize, Serialize};
use super::grouped::{self, IVF_PARTITION};
use super::refresh::{
RefreshMaterializedViewResult, RefreshMode, ensure_incarnation, ensure_no_mem_wal, open_source,
refresh_lock, stamp_watermark, to_view_batch,
};
use super::{MaterializedViewDefinition, StoredDefinition};
use crate::table::Table;
use crate::{Error, Result};
const TAKE_CHUNK: usize = 8192;
const PROBE_PER_UNIT: usize = 8;
const PROBE_ROWS: usize = 256;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct GroupedRefreshPlan {
pub units: u32,
pub source_version: u64,
pub view_version: u64,
pub incarnation: String,
}
fn ivf_key_column(definition: &MaterializedViewDefinition) -> Option<String> {
let [key] = definition.group_by.as_slice() else {
return None;
};
let inner = key
.strip_prefix(&format!("{IVF_PARTITION}("))?
.strip_suffix(')')?;
let column = inner.trim().trim_matches('`');
(!column.is_empty() && !column.contains(',')).then(|| column.to_string())
}
struct Grouped {
view_ds: Dataset,
definition: MaterializedViewDefinition,
source: Dataset,
column: String,
inputs: Vec<String>,
binding: grouped::IvfBinding,
segments: grouped::IvfSegments,
}
async fn open_grouped(view: &Table, pinned: Option<u64>) -> Result<Option<Grouped>> {
let view_native = view.as_native().ok_or_else(|| Error::NotSupported {
message: "materialized views are supported only on local tables".into(),
})?;
view_native.dataset.reload().await?;
let view_ds = view_native.dataset.get().await?.as_ref().clone();
let Some(StoredDefinition::Query(definition)) =
super::read_definition(&view_ds.schema().metadata)?
else {
return Ok(None);
};
let Some(column) = ivf_key_column(&definition) else {
return Ok(None);
};
let staging = super::read_staging(&view_ds.schema().metadata)?;
let source = open_source(view, &definition, staging.as_ref()).await?;
let source = match pinned {
Some(version) => source.checkout_version(version).await?,
None => source,
};
ensure_no_mem_wal(&source, "source table", &definition.source_table).await?;
let source_schema = Arc::new(ArrowSchema::from(source.schema()));
let planned = super::plan(source_schema, &definition, staging.as_ref())?;
let (binding, segments) = grouped::bind_column_with_segments(&source, &column).await?;
Ok(Some(Grouped {
view_ds,
definition: planned.definition,
source,
column,
inputs: planned.inputs,
binding,
segments,
}))
}
pub async fn plan_grouped_refresh(
view: &Table,
source_version: Option<u64>,
) -> Result<Option<GroupedRefreshPlan>> {
let Some(grouped) = open_grouped(view, source_version).await? else {
return Ok(None);
};
if grouped.segments.covered.is_none() {
return Ok(None);
}
let Some(incarnation) = grouped
.view_ds
.schema()
.metadata
.get(super::INCARNATION_META_KEY)
.cloned()
else {
return Ok(None);
};
if !assigns_by_its_centroids(&grouped).await? {
return Ok(None);
}
Ok(Some(GroupedRefreshPlan {
units: grouped.binding.partitions + 1,
source_version: grouped.source.version().version,
view_version: grouped.view_ds.version().version,
incarnation,
}))
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct WrittenUnit {
unit: u32,
plan: GroupedRefreshPlan,
fragments: Vec<Fragment>,
rows: u64,
}
impl WrittenUnit {
pub fn unit(&self) -> u32 {
self.unit
}
pub fn plan(&self) -> &GroupedRefreshPlan {
&self.plan
}
pub fn rows(&self) -> u64 {
self.rows
}
pub fn fragments(&self) -> &[Fragment] {
&self.fragments
}
}
async fn open_planned(view: &Table, plan: &GroupedRefreshPlan) -> Result<Grouped> {
let grouped = open_grouped(view, Some(plan.source_version))
.await?
.ok_or_else(|| Error::InvalidInput {
message: format!(
"'{}' is not a grouped view keyed by {IVF_PARTITION}",
view.name()
),
})?;
ensure_incarnation(&grouped.view_ds, Some(&plan.incarnation), view.name()).await?;
Ok(grouped)
}
pub async fn write_grouped_unit(
view: &Table,
unit: u32,
plan: &GroupedRefreshPlan,
) -> Result<WrittenUnit> {
let grouped = open_planned(view, plan).await?;
if unit >= plan.units {
return Err(Error::InvalidInput {
message: format!(
"unit {unit} is not in the plan of '{}' ({} units)",
view.name(),
plan.units
),
});
}
let schema: SchemaRef = Arc::new(ArrowSchema::from(grouped.view_ds.schema()));
let rows_written = Arc::new(AtomicU64::new(0));
let rows = unit_rows(&grouped, unit).await?;
let stream = group_rows(&grouped, rows, schema.clone(), rows_written.clone()).await?;
let write_txn = InsertBuilder::new(WriteDestination::Dataset(Arc::new(grouped.view_ds)))
.with_params(&WriteParams {
mode: WriteMode::Append,
..Default::default()
})
.execute_uncommitted_stream(stream)
.await?;
let Operation::Append { fragments } = write_txn.operation else {
return Err(Error::Runtime {
message: "expected an append when writing a grouped unit".into(),
});
};
Ok(WrittenUnit {
unit,
plan: plan.clone(),
fragments,
rows: rows_written.load(Ordering::Relaxed),
})
}
async fn indexed_row_ids(grouped: &Grouped, unit: u32, limit: usize) -> Result<Vec<u64>> {
let addresses = lance::dataset::rowids::get_row_id_index(&grouped.source).await?;
let fragment_of = |row_id: u64| -> Result<Option<u32>> {
Ok(match &addresses {
Some(index) => index.get(row_id)?.map(|address| address.fragment_id()),
None => Some(RowAddress::new_from_u64(row_id).fragment_id()),
})
};
let mut row_ids = Vec::new();
for segment in &grouped.segments.segments {
let owned = segment.fragments.as_ref().ok_or_else(|| Error::Runtime {
message: "the index stopped naming the fragments it covers mid-refresh".into(),
})?;
let mut stream = segment
.index
.partition_reader(unit as usize, false, &NoOpMetricsCollector)
.await?;
while let Some(batch) = stream.try_next().await? {
for row_id in batch[ROW_ID].as_primitive::<UInt64Type>().values() {
if row_ids.len() >= limit {
return Ok(row_ids);
}
if fragment_of(*row_id)?.is_some_and(|fragment| owned.contains(fragment)) {
row_ids.push(*row_id);
}
}
}
}
Ok(row_ids)
}
fn assigned_elsewhere(grouped: &Grouped, batch: &RecordBatch, unit: u32) -> Result<Option<usize>> {
let vectors = batch[grouped.column.as_str()].as_fixed_size_list();
let assigned = grouped.binding.assign(vectors)?;
Ok((0..batch.num_rows()).find(|&row| !(assigned.is_valid(row) && assigned.value(row) == unit)))
}
async fn assigns_by_its_centroids(grouped: &Grouped) -> Result<bool> {
let mut expected = HashMap::new();
for unit in 0..grouped.binding.partitions {
if expected.len() >= PROBE_ROWS {
break;
}
for row_id in indexed_row_ids(grouped, unit, PROBE_PER_UNIT).await? {
expected.insert(row_id, unit);
}
}
if expected.is_empty() {
return Ok(true);
}
let sample: Vec<u64> = expected.keys().copied().collect();
let columns = [ROW_ID, grouped.column.as_str()];
let projection = ProjectionRequest::from_columns(columns, grouped.source.schema());
let batch = grouped.source.take_rows(&sample, projection).await?;
let row_ids = batch[ROW_ID].as_primitive::<UInt64Type>().clone();
let assigned = grouped
.binding
.assign(batch[grouped.column.as_str()].as_fixed_size_list())?;
Ok((0..batch.num_rows()).all(|row| {
assigned.is_valid(row) && Some(&assigned.value(row)) == expected.get(&row_ids.value(row))
}))
}
async fn unit_rows(grouped: &Grouped, unit: u32) -> Result<Vec<RecordBatch>> {
let mut columns: Vec<&str> = vec![ROW_ID];
columns.extend(grouped.inputs.iter().map(String::as_str));
if !grouped.inputs.contains(&grouped.column) {
columns.push(&grouped.column);
}
let source = Arc::new(grouped.source.clone());
let mut batches = Vec::new();
let unassigned = unit == grouped.binding.partitions;
if !unassigned {
let row_ids = indexed_row_ids(grouped, unit, usize::MAX).await?;
let projection = ProjectionRequest::from_columns(&columns, source.schema());
for chunk in row_ids.chunks(TAKE_CHUNK) {
let batch = source.take_rows(chunk, projection.clone()).await?;
if batch.num_rows() > 0 {
if let Some(row) = assigned_elsewhere(grouped, &batch, unit)? {
let row_id = batch[ROW_ID].as_primitive::<UInt64Type>().value(row);
return Err(Error::NotSupported {
message: format!(
"{IVF_PARTITION}({}): the index lists row {row_id} under \
partition {unit}, which it does not assign to; this view \
is refreshed in one pass",
grouped.column
),
});
}
batches.push(in_column_order(&batch, &columns)?);
}
}
}
let mut fragments = Vec::new();
for fragment in source.get_fragments() {
let unindexed = grouped
.segments
.is_unindexed(fragment.id() as u64)
.ok_or_else(|| Error::Runtime {
message: "the index stopped naming the fragments it covers mid-refresh".into(),
})?;
if unassigned || unindexed {
fragments.push(fragment.metadata().clone());
}
}
if !fragments.is_empty() {
let mut scanner = source.scan();
scanner.with_fragments(fragments);
scanner.with_row_id();
scanner.project(&columns)?;
let mut stream = scanner.try_into_stream().await?;
while let Some(batch) = stream.try_next().await? {
let vectors = batch[grouped.column.as_str()].as_fixed_size_list();
let assigned = grouped.binding.assign(vectors)?;
let keep: arrow_array::BooleanArray = (0..batch.num_rows())
.map(|row| {
Some(if unassigned {
assigned.is_null(row)
} else {
assigned.is_valid(row) && assigned.value(row) == unit
})
})
.collect();
let kept = arrow_select::filter::filter_record_batch(&batch, &keep)?;
if kept.num_rows() > 0 {
batches.push(in_column_order(&kept, &columns)?);
}
}
}
Ok(batches)
}
fn in_column_order(batch: &RecordBatch, columns: &[&str]) -> Result<RecordBatch> {
let indices = columns
.iter()
.map(|column| batch.schema().index_of(column))
.collect::<std::result::Result<Vec<_>, _>>()?;
Ok(batch.project(&indices)?)
}
async fn group_rows(
grouped: &Grouped,
rows: Vec<RecordBatch>,
schema: SchemaRef,
rows_written: Arc<AtomicU64>,
) -> Result<SendableRecordBatchStream> {
let input_schema = match rows.first() {
Some(batch) => batch.schema(),
None => {
return Ok(Box::pin(RecordBatchStreamAdapter::new(
schema,
futures::stream::empty(),
)));
}
};
let rows: SendableRecordBatchStream = Box::pin(RecordBatchStreamAdapter::new(
input_schema,
futures::stream::iter(rows.into_iter().map(Ok)),
));
let state = grouped::session(grouped::bind(&grouped.source, &grouped.definition).await?);
let ctx = SessionContext::new_with_state(state.clone());
let source = ctx.read_one_shot(rows)?.into_view();
let planned = grouped::logical_plan(
&state,
provider_as_source(source),
&grouped.definition,
true,
)?;
let groups = ctx
.execute_logical_plan(planned)
.await?
.execute_stream()
.await?;
let out_schema = schema.clone();
let mapped = groups.map(move |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 async fn commit_grouped_refresh(
view: &Table,
plan: &GroupedRefreshPlan,
units: Vec<WrittenUnit>,
expected_incarnation: Option<&str>,
) -> Result<RefreshMaterializedViewResult> {
let grouped = open_planned(view, plan).await?;
let units = complete_set(view.name(), plan, units)?;
let rows_written = units.iter().map(|unit| unit.rows).sum();
let new_fragments = units.into_iter().flat_map(|unit| unit.fragments).collect();
commit_fragments(
view,
plan,
&grouped.definition,
grouped.source.manifest.timestamp_nanos,
new_fragments,
rows_written,
expected_incarnation,
)
.await
}
#[allow(clippy::too_many_arguments)]
pub(super) async fn commit_fragments(
view: &Table,
plan: &GroupedRefreshPlan,
definition: &MaterializedViewDefinition,
source_ts: u128,
new_fragments: Vec<Fragment>,
rows_written: u64,
expected_incarnation: Option<&str>,
) -> Result<RefreshMaterializedViewResult> {
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;
if let Some(expected) = expected_incarnation
&& expected != plan.incarnation
{
return Err(Error::Runtime {
message: format!(
"the refresh of '{}' was requested for another incarnation than the one \
its units were planned against",
view.name()
),
});
}
view_native.dataset.reload().await?;
let ds = view_native.dataset.get().await?.as_ref().clone();
ensure_incarnation(&ds, Some(&plan.incarnation), view.name()).await?;
if super::read_definition(&ds.schema().metadata)?
!= Some(super::StoredDefinition::Query(definition.clone()))
{
return Err(Error::InvalidInput {
message: "view definition changed while its units were written".into(),
});
}
let ds = Arc::new(ds);
if ds.version().version != plan.view_version {
return Err(Error::Runtime {
message: format!(
"'{}' moved from version {} to {} while its units were written; the \
refresh is unrecorded and must be replanned",
view.name(),
plan.view_version,
ds.version().version
),
});
}
let removed_fragment_ids: Vec<u64> = ds.get_fragments().iter().map(|f| f.id() as u64).collect();
let transaction = Transaction::new(
plan.view_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: None,
updated_fragment_offsets: None,
},
None,
);
#[cfg(test)]
super::refresh::tests::hold_before_publish(ds.uri()).await;
let committed = CommitBuilder::new(WriteDestination::Dataset(ds))
.execute(transaction)
.await?;
if committed.version().version != plan.view_version + 1 {
return Err(Error::Runtime {
message: format!(
"a concurrent commit raced the refresh of '{}' (view version {}); the \
refresh is unrecorded and must be replanned",
view.name(),
committed.version().version
),
});
}
let version = stamp_watermark(
view_native,
committed,
plan.source_version,
source_ts,
Some(definition),
expected_incarnation,
)
.await?;
Ok(RefreshMaterializedViewResult {
mode: RefreshMode::Rebuild,
rows_written,
source_version: plan.source_version,
version,
})
}
fn complete_set(
view: &str,
plan: &GroupedRefreshPlan,
units: Vec<WrittenUnit>,
) -> Result<Vec<WrittenUnit>> {
let mut ordered: Vec<Option<WrittenUnit>> = vec![None; plan.units as usize];
for written in units {
if &written.plan != plan {
return Err(Error::InvalidInput {
message: format!(
"a unit of another plan of '{view}' was offered to this refresh; the \
refresh is unrecorded and must be replanned"
),
});
}
let slot = ordered
.get_mut(written.unit as usize)
.ok_or_else(|| Error::InvalidInput {
message: format!(
"unit {} is not in the plan of '{view}' ({} units)",
written.unit, plan.units
),
})?;
if slot.is_some() {
return Err(Error::InvalidInput {
message: format!("unit {} of '{view}' was written twice", written.unit),
});
}
if written.rows > 1 {
return Err(Error::InvalidInput {
message: format!(
"unit {} of '{view}' wrote {} groups; the split did not match the \
grouping and the refresh must be replanned",
written.unit, written.rows
),
});
}
*slot = Some(written);
}
ordered
.into_iter()
.enumerate()
.map(|(unit, written)| {
written.ok_or_else(|| Error::InvalidInput {
message: format!(
"unit {unit} of '{view}' is missing; the refresh holds {} units and \
publishes all of them or none",
plan.units
),
})
})
.collect()
}
#[cfg(test)]
mod tests {
use super::super::refresh::tests::{clustered_source, declare, rows};
use super::*;
use crate::connect;
use crate::index::Index;
use crate::index::vector::IvfFlatIndexBuilder;
use arrow_array::types::Float32Type;
use arrow_array::{ArrayRef, FixedSizeListArray, Int32Array, RecordBatchIterator};
use lance::index::DatasetIndexExt;
use lance::index::vector::VectorIndexParams;
use lance_index::IndexType;
use lance_index::vector::ivf::IvfBuildParams;
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)";
async fn edited_source(conn: &crate::Connection) -> Table {
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();
source.delete("id IN (3, 13)").await.unwrap();
let appended = FixedSizeListArray::from_iter_primitive::<Float32Type, _, _>(
[
Some(vec![Some(1.5), Some(0.0)]),
Some(vec![Some(101.5), Some(100.0)]),
Some(vec![Some(0.0), Some(0.0)]),
],
2,
);
source
.add(
RecordBatch::try_from_iter(vec![
(
"id",
Arc::new(Int32Array::from(vec![20, 21, 22])) as ArrayRef,
),
("vec", Arc::new(appended) as ArrayRef),
])
.unwrap(),
)
.execute()
.await
.unwrap();
source
}
async fn refresh_in_units(
view: &Table,
expected_incarnation: Option<&str>,
) -> Result<(
GroupedRefreshPlan,
Vec<WrittenUnit>,
RefreshMaterializedViewResult,
)> {
let plan = plan_grouped_refresh(view, None)
.await?
.expect("an ivf-keyed view");
let mut units = Vec::new();
for unit in 0..plan.units {
units.push(write_grouped_unit(view, unit, &plan).await?);
}
let result =
commit_grouped_refresh(view, &plan, units.clone(), expected_incarnation).await?;
Ok((plan, units, result))
}
#[tokio::test]
async fn units_compose_to_the_single_pass_result() {
let conn = connect("memory://").execute().await.unwrap();
let source = edited_source(&conn).await;
let single = declare(&source, "single", BUCKETS).await.unwrap();
single.refresh().execute().await.unwrap();
let expected = rows(single.table(), &["bucket", "n", "lo", "hi"]).await;
assert_eq!(expected.len(), 3, "{expected:?}");
assert!(expected.iter().any(|row| row == " 1 22 22"), "{expected:?}");
let view = declare(&source, "buckets", BUCKETS).await.unwrap();
let (plan, units, result) = refresh_in_units(view.table(), None).await.unwrap();
assert_eq!(plan.units, 3, "two partitions plus the unplaceable rows");
assert_eq!(
rows(view.table(), &["bucket", "n", "lo", "hi"]).await,
expected
);
assert_eq!(units.iter().map(|u| u.rows).sum::<u64>(), 3);
assert_eq!(units.iter().map(|u| u.rows).collect::<Vec<_>>(), [1, 1, 1]);
assert_eq!(result.rows_written, 3);
assert_eq!(result.source_version, plan.source_version);
assert_eq!(view.table().version().await.unwrap(), result.version);
let again = view.refresh().execute().await.unwrap();
assert_eq!(again.rows_written, 0, "{again:?}");
assert_eq!(
rows(view.table(), &["bucket", "n", "lo", "hi"]).await,
expected
);
}
#[tokio::test]
async fn a_refresh_in_units_replaces_the_previous_rows() {
let conn = connect("memory://").execute().await.unwrap();
let source = edited_source(&conn).await;
let view = declare(&source, "buckets", BUCKETS).await.unwrap();
refresh_in_units(view.table(), None).await.unwrap();
source.delete("id = 22").await.unwrap();
refresh_in_units(view.table(), None).await.unwrap();
let mut groups = rows(view.table(), &["n", "lo", "hi"]).await;
groups.sort();
assert_eq!(groups, ["10 0 20", "10 10 21"]);
}
#[tokio::test]
async fn a_view_not_keyed_by_ivf_partition_has_no_units() {
let conn = connect("memory://").execute().await.unwrap();
let source = clustered_source(&conn).await;
let view = declare(
&source,
"by_id",
"SELECT id % 2 AS k, count(*) AS n FROM images GROUP BY id % 2",
)
.await
.unwrap();
assert_eq!(
plan_grouped_refresh(view.table(), None).await.unwrap(),
None
);
}
#[tokio::test]
async fn a_unit_outside_the_plan_is_refused() {
let conn = connect("memory://").execute().await.unwrap();
let source = edited_source(&conn).await;
let view = declare(&source, "buckets", BUCKETS).await.unwrap();
let plan = plan_grouped_refresh(view.table(), None)
.await
.unwrap()
.unwrap();
let err = write_grouped_unit(view.table(), plan.units, &plan)
.await
.unwrap_err();
assert!(err.to_string().contains("not in the plan"), "{err}");
}
#[tokio::test]
async fn a_raced_commit_is_refused() {
let conn = connect("memory://").execute().await.unwrap();
let source = edited_source(&conn).await;
let view = declare(&source, "buckets", BUCKETS).await.unwrap();
let plan = plan_grouped_refresh(view.table(), None)
.await
.unwrap()
.unwrap();
let units = vec![write_grouped_unit(view.table(), 0, &plan).await.unwrap()];
view.refresh().execute().await.unwrap();
let before = rows(view.table(), &["bucket", "n", "lo", "hi"]).await;
let raced = commit_grouped_refresh(view.table(), &plan, units, None).await;
assert!(raced.is_err(), "{raced:?}");
assert_eq!(
rows(view.table(), &["bucket", "n", "lo", "hi"]).await,
before
);
}
#[tokio::test]
async fn a_filtered_view_refreshes_in_units_like_a_single_pass() {
const FILTERED: &str = "SELECT ivf_partition(vec) AS bucket, count(*) AS n, \
min(id) AS lo, max(id) AS hi FROM images WHERE id < 5 \
GROUP BY ivf_partition(vec)";
let conn = connect("memory://").execute().await.unwrap();
let source = edited_source(&conn).await;
let single = declare(&source, "single", FILTERED).await.unwrap();
single.refresh().execute().await.unwrap();
let expected = rows(single.table(), &["bucket", "n", "lo", "hi"]).await;
let view = declare(&source, "buckets", FILTERED).await.unwrap();
refresh_in_units(view.table(), None).await.unwrap();
assert_eq!(
rows(view.table(), &["bucket", "n", "lo", "hi"]).await,
expected
);
}
#[tokio::test]
async fn only_a_complete_set_of_this_plan_s_units_is_published() {
let conn = connect("memory://").execute().await.unwrap();
let source = edited_source(&conn).await;
let view = declare(&source, "buckets", BUCKETS).await.unwrap();
let plan = plan_grouped_refresh(view.table(), None)
.await
.unwrap()
.unwrap();
let mut units = Vec::new();
for unit in 0..plan.units {
units.push(write_grouped_unit(view.table(), unit, &plan).await.unwrap());
}
let missing = commit_grouped_refresh(view.table(), &plan, units[..2].to_vec(), None).await;
assert!(
missing.unwrap_err().to_string().contains("is missing"),
"a partial set was published"
);
let mut twice = units.clone();
twice[1] = units[0].clone();
let repeated = commit_grouped_refresh(view.table(), &plan, twice, None).await;
assert!(
repeated.unwrap_err().to_string().contains("written twice"),
"a repeated unit was published"
);
let mut foreign = units.clone();
foreign[0].plan.source_version += 1;
let stale = commit_grouped_refresh(view.table(), &plan, foreign, None).await;
assert!(
stale.unwrap_err().to_string().contains("another plan"),
"a unit of another plan was published"
);
assert_eq!(rows(view.table(), &["bucket"]).await, Vec::<String>::new());
commit_grouped_refresh(view.table(), &plan, units, None)
.await
.unwrap();
assert_eq!(rows(view.table(), &["bucket"]).await.len(), 3);
}
#[tokio::test]
async fn a_rewritten_fragment_is_read_once() {
use lance::dataset::{
MergeInsertBuilder, MergeInsertWriteMode, WhenMatched, WhenNotMatched,
};
let conn = connect("memory://").execute().await.unwrap();
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 source = conn
.create_table(
"images",
RecordBatch::try_from_iter(vec![
(
"id",
Arc::new(Int32Array::from_iter_values(0..20)) as ArrayRef,
),
("vec", Arc::new(vectors) as ArrayRef),
(
"tag",
Arc::new(Int32Array::from_iter_values((0..20).map(|i| i * 10))) as ArrayRef,
),
])
.unwrap(),
)
.write_options(super::super::tests::stable_row_ids())
.execute()
.await
.unwrap();
source
.create_index(
&["vec"],
Index::IvfFlat(IvfFlatIndexBuilder::default().num_partitions(2)),
)
.execute()
.await
.unwrap();
let batch = RecordBatch::try_from_iter(vec![
("id", Arc::new(Int32Array::from(vec![0])) as ArrayRef),
(
"vec",
Arc::new(
FixedSizeListArray::from_iter_primitive::<Float32Type, _, _>(
[Some(vec![Some(101.0), Some(100.0)])],
2,
),
) as ArrayRef,
),
])
.unwrap();
let schema = batch.schema();
let dataset = source.as_native().unwrap().dataset.get().await.unwrap();
let mut builder =
MergeInsertBuilder::try_new(Arc::new(dataset.as_ref().clone()), vec!["id".to_string()])
.unwrap();
builder
.when_matched(WhenMatched::UpdateAll)
.when_not_matched(WhenNotMatched::DoNothing)
.write_mode(MergeInsertWriteMode::RewriteColumns);
builder
.try_build()
.unwrap()
.execute_reader(arrow_array::RecordBatchIterator::new([Ok(batch)], schema))
.await
.unwrap();
let single = declare(&source, "single", BUCKETS).await.unwrap();
single.refresh().execute().await.unwrap();
let expected = rows(single.table(), &["bucket", "n", "lo", "hi"]).await;
let view = declare(&source, "buckets", BUCKETS).await.unwrap();
refresh_in_units(view.table(), None).await.unwrap();
assert_eq!(
rows(view.table(), &["bucket", "n", "lo", "hi"]).await,
expected,
"the rewritten row was counted twice or bucketed by its old vector"
);
}
#[tokio::test]
async fn a_rebuilt_index_does_not_reach_the_units_of_a_plan() {
let conn = connect("memory://").execute().await.unwrap();
let source = edited_source(&conn).await;
let view = declare(&source, "buckets", BUCKETS).await.unwrap();
let plan = plan_grouped_refresh(view.table(), None)
.await
.unwrap()
.unwrap();
let first = write_grouped_unit(view.table(), 0, &plan).await.unwrap();
source
.create_index(
&["vec"],
Index::IvfFlat(
IvfFlatIndexBuilder::default()
.num_partitions(2)
.distance_type(crate::DistanceType::Cosine),
),
)
.replace(true)
.execute()
.await
.unwrap();
let rebuilt = plan_grouped_refresh(view.table(), None)
.await
.unwrap()
.unwrap();
assert_eq!(rebuilt.units, plan.units, "same shape");
assert!(
rebuilt.source_version > plan.source_version,
"the rebuild is a later source version"
);
let mut units = vec![first];
for unit in 1..plan.units {
units.push(write_grouped_unit(view.table(), unit, &plan).await.unwrap());
}
commit_grouped_refresh(view.table(), &plan, units, None)
.await
.unwrap();
assert_eq!(
rows(view.table(), &["bucket", "n", "lo", "hi"]).await.len(),
3
);
}
#[tokio::test]
async fn units_written_for_another_view_are_refused() {
let conn = connect("memory://").execute().await.unwrap();
let source = edited_source(&conn).await;
let mine = declare(&source, "buckets", BUCKETS).await.unwrap();
let theirs = declare(&source, "twin", BUCKETS).await.unwrap();
let my_plan = plan_grouped_refresh(mine.table(), None)
.await
.unwrap()
.unwrap();
let their_plan = plan_grouped_refresh(theirs.table(), None)
.await
.unwrap()
.unwrap();
assert_eq!(
(my_plan.units, my_plan.view_version, my_plan.source_version),
(
their_plan.units,
their_plan.view_version,
their_plan.source_version
),
"the counters alone do not tell these views apart"
);
let mut mine_units = Vec::new();
for unit in 0..my_plan.units {
mine_units.push(
write_grouped_unit(mine.table(), unit, &my_plan)
.await
.unwrap(),
);
}
let misrouted =
commit_grouped_refresh(theirs.table(), &their_plan, mine_units.clone(), None).await;
assert!(
misrouted.unwrap_err().to_string().contains("another plan"),
"another view's units were published"
);
assert_eq!(
rows(theirs.table(), &["bucket"]).await,
Vec::<String>::new()
);
let foreign = write_grouped_unit(theirs.table(), 0, &my_plan).await;
assert!(
foreign
.unwrap_err()
.to_string()
.contains("not the incarnation"),
"a unit was written under another view's plan"
);
}
#[tokio::test(flavor = "multi_thread")]
async fn a_write_racing_the_publication_is_refused() {
use super::super::refresh::tests::{
DRIFT_LOCK, DRIFT_PLANNED, DRIFT_RELEASED, DRIFT_TARGET,
};
let _serial = DRIFT_LOCK.lock().await;
let conn = connect("memory://").execute().await.unwrap();
let source = edited_source(&conn).await;
let view = declare(&source, "racing_buckets", BUCKETS).await.unwrap();
let plan = plan_grouped_refresh(view.table(), None)
.await
.unwrap()
.unwrap();
let mut units = Vec::new();
for unit in 0..plan.units {
units.push(write_grouped_unit(view.table(), unit, &plan).await.unwrap());
}
let uri = view
.table()
.as_native()
.unwrap()
.dataset
.get()
.await
.unwrap()
.uri()
.to_string();
*DRIFT_TARGET.lock().unwrap() = Some(uri);
let publishing = {
let table = view.table().clone();
let plan = plan.clone();
tokio::spawn(async move { commit_grouped_refresh(&table, &plan, units, None).await })
};
tokio::time::timeout(std::time::Duration::from_secs(30), DRIFT_PLANNED.notified())
.await
.expect("the commit never reached the publication boundary");
view.table()
.add(
RecordBatch::try_from_iter(vec![
("bucket", Arc::new(Int32Array::from(vec![7])) as ArrayRef),
("n", Arc::new(Int32Array::from(vec![1])) as ArrayRef),
("lo", Arc::new(Int32Array::from(vec![0])) as ArrayRef),
("hi", Arc::new(Int32Array::from(vec![0])) as ArrayRef),
(
super::super::SOURCE_ROW_ID_COLUMN,
Arc::new(arrow_array::UInt64Array::from(vec![0u64])) as ArrayRef,
),
])
.unwrap(),
)
.execute()
.await
.unwrap();
DRIFT_RELEASED.notify_one();
let err = publishing.await.unwrap().unwrap_err();
assert!(err.to_string().contains("raced the refresh"), "{err}");
}
#[test]
fn the_key_column_is_read_off_a_single_ivf_key() {
let mut definition = MaterializedViewDefinition::from_sql(
"SELECT ivf_partition(vec) AS b, count(*) AS n FROM t GROUP BY ivf_partition(vec)",
)
.unwrap();
assert_eq!(ivf_key_column(&definition).as_deref(), Some("vec"));
definition.group_by = vec!["k".to_string()];
assert_eq!(ivf_key_column(&definition), None);
definition.group_by = vec!["ivf_partition(vec)".to_string(), "k".to_string()];
assert_eq!(ivf_key_column(&definition), None);
}
async fn precomputed_source(conn: &crate::Connection, dir: &std::path::Path) -> Table {
let vectors = FixedSizeListArray::from_iter_primitive::<Float32Type, _, _>(
[
Some(vec![Some(1.0), Some(0.0)]),
Some(vec![Some(2.0), Some(0.0)]),
Some(vec![Some(100.0), Some(100.0)]),
],
2,
);
let source = conn
.create_table(
"images",
RecordBatch::try_from_iter(vec![
("id", Arc::new(Int32Array::from(vec![0, 1, 2])) as ArrayRef),
("vec", Arc::new(vectors) as ArrayRef),
])
.unwrap(),
)
.write_options(crate::materialized_view::tests::stable_row_ids())
.execute()
.await
.unwrap();
let assignments = arrow_array::record_batch!(
("row_id", UInt64, [0, 1, 2]),
("partition", UInt32, [0, 1, 1])
)
.unwrap();
let file = dir.join("partitions.lance");
let file = file.to_str().unwrap();
lance::Dataset::write(
RecordBatchIterator::new(vec![Ok(assignments.clone())], assignments.schema()),
file,
None,
)
.await
.unwrap();
let centroids = FixedSizeListArray::from_iter_primitive::<Float32Type, _, _>(
[
Some(vec![Some(0.0), Some(0.0)]),
Some(vec![Some(100.0), Some(100.0)]),
],
2,
);
let mut ivf = IvfBuildParams::try_with_centroids(2, Arc::new(centroids)).unwrap();
ivf.precomputed_partitions_file = Some(file.to_string());
let mut dataset = source
.as_native()
.unwrap()
.dataset
.get()
.await
.unwrap()
.as_ref()
.clone();
dataset
.create_index(
&["vec"],
IndexType::Vector,
None,
&VectorIndexParams::with_ivf_flat_params(
lance_linalg::distance::DistanceType::L2,
ivf,
),
true,
)
.await
.unwrap();
conn.open_table("images").execute().await.unwrap()
}
async fn incarnation(view: &Table) -> String {
view.as_native()
.unwrap()
.dataset
.get()
.await
.unwrap()
.schema()
.metadata
.get(crate::materialized_view::INCARNATION_META_KEY)
.cloned()
.unwrap()
}
#[tokio::test]
async fn an_index_that_does_not_assign_by_its_centroids_has_no_units() {
let dir = tempfile::tempdir().unwrap();
let conn = connect("memory://").execute().await.unwrap();
let source = precomputed_source(&conn, dir.path()).await;
let view = declare(&source, "buckets", BUCKETS).await.unwrap();
assert_eq!(
plan_grouped_refresh(view.table(), None).await.unwrap(),
None
);
view.refresh().execute().await.unwrap();
assert_eq!(
rows(view.table(), &["bucket", "n", "lo", "hi"]).await,
["0 2 0 1", "1 1 2 2"]
);
}
#[tokio::test]
async fn a_unit_that_wrote_more_than_one_group_is_not_published() {
let conn = connect("memory://").execute().await.unwrap();
let source = edited_source(&conn).await;
let view = declare(&source, "buckets", BUCKETS).await.unwrap();
let plan = plan_grouped_refresh(view.table(), None)
.await
.unwrap()
.unwrap();
let mut units = Vec::new();
for unit in 0..plan.units {
units.push(write_grouped_unit(view.table(), unit, &plan).await.unwrap());
}
units[1].rows = 2;
let err = commit_grouped_refresh(view.table(), &plan, units, None)
.await
.unwrap_err();
assert!(err.to_string().contains("wrote 2 groups"), "{err}");
assert_eq!(rows(view.table(), &["bucket"]).await, Vec::<String>::new());
}
#[tokio::test]
async fn a_unit_holding_a_row_that_assigns_elsewhere_is_refused() {
let dir = tempfile::tempdir().unwrap();
let conn = connect("memory://").execute().await.unwrap();
let source = precomputed_source(&conn, dir.path()).await;
let view = declare(&source, "buckets", BUCKETS).await.unwrap();
let plan = GroupedRefreshPlan {
units: 3,
source_version: source.version().await.unwrap(),
view_version: view.table().version().await.unwrap(),
incarnation: incarnation(view.table()).await,
};
assert_eq!(
write_grouped_unit(view.table(), 0, &plan)
.await
.unwrap()
.rows,
1
);
let err = write_grouped_unit(view.table(), 1, &plan)
.await
.unwrap_err();
assert!(err.to_string().contains("does not assign to"), "{err}");
}
}