use super::load_row_id_sequence;
use crate::dataset::Dataset;
use crate::dataset::fragment::FileFragment;
use crate::{Error, Result};
use futures::{StreamExt, TryStreamExt};
use lance_core::utils::deletion::DeletionVector;
use lance_table::format::RowDatasetVersionMeta;
use lance_table::rowids::RowIdSequence;
use roaring::RoaringTreemap;
use std::sync::Arc;
type FragmentRowIds = (
FileFragment,
Arc<RowIdSequence>,
Option<Arc<DeletionVector>>,
);
pub async fn validate_stable_row_ids(dataset: &Dataset) -> Result<()> {
if !dataset.manifest.uses_stable_row_ids() {
return Ok(());
}
let corrupt = |message: String| Error::corrupt_file(dataset.base.clone(), message);
for fragment in dataset.manifest.fragments.iter() {
if fragment.row_id_meta.is_none() {
return Err(corrupt(format!(
"Fragment {} has no row id metadata, but dataset {:?} uses stable row ids",
fragment.id, dataset.base
)));
}
}
let fragments: Vec<FragmentRowIds> = futures::stream::iter(dataset.get_fragments())
.map(|fragment| async move {
let sequence = load_row_id_sequence(dataset, fragment.metadata()).await?;
let deletion_vector = fragment.get_deletion_vector().await?;
Result::Ok((fragment, sequence, deletion_vector))
})
.buffered(dataset.object_store.io_parallelism())
.try_collect()
.await?;
let mut live_row_ids = RoaringTreemap::new();
let mut max_row_id: Option<u64> = None;
for (index, (fragment, sequence, deletion_vector)) in fragments.iter().enumerate() {
let metadata = fragment.metadata();
let physical_rows = metadata.physical_rows.ok_or_else(|| {
corrupt(format!(
"Fragment {} has an unknown physical row count, but dataset {:?} uses stable row ids",
metadata.id, dataset.base
))
})? as u64;
if sequence.len() != physical_rows {
return Err(corrupt(format!(
"Fragment {} has {} row ids, but {} physical rows, in dataset {:?}",
metadata.id,
sequence.len(),
physical_rows,
dataset.base
)));
}
for (name, meta) in [
("created_at", &metadata.created_at_version_meta),
("last_updated_at", &metadata.last_updated_at_version_meta),
] {
if let Some(meta @ RowDatasetVersionMeta::Inline(_)) = meta {
let versions = meta.load_sequence()?.len();
if versions != physical_rows {
return Err(corrupt(format!(
"Fragment {} has {} {} versions, but {} physical rows, in dataset {:?}",
metadata.id, versions, name, physical_rows, dataset.base
)));
}
}
}
if let Some(range) = sequence.row_id_range() {
max_row_id = max_row_id.max(Some(*range.end()));
}
for (offset, row_id) in sequence.iter().enumerate() {
if deletion_vector
.as_ref()
.is_some_and(|deletions| deletions.contains(offset as u32))
{
continue;
}
if !live_row_ids.insert(row_id) {
return Err(corrupt(format!(
"Row id {} is live in both {} and fragment {} at offset {} in dataset {:?}",
row_id,
describe_first_live_slot(&fragments[..=index], row_id),
metadata.id,
offset,
dataset.base
)));
}
}
}
if let Some(max_row_id) = max_row_id
&& dataset.manifest.next_row_id <= max_row_id
{
return Err(corrupt(format!(
"Dataset {:?} will hand out row id {} next, but row id {} is already in use",
dataset.base, dataset.manifest.next_row_id, max_row_id
)));
}
Ok(())
}
fn describe_first_live_slot(fragments: &[FragmentRowIds], row_id: u64) -> String {
for (fragment, sequence, deletion_vector) in fragments {
if sequence
.row_id_range()
.is_none_or(|range| !range.contains(&row_id))
{
continue;
}
for (offset, candidate) in sequence.iter().enumerate() {
if candidate == row_id
&& !deletion_vector
.as_ref()
.is_some_and(|deletions| deletions.contains(offset as u32))
{
return format!("fragment {} at offset {}", fragment.id(), offset);
}
}
}
"an earlier fragment".to_string()
}
#[cfg(test)]
mod tests {
use super::*;
use super::super::test::{compact, delete};
use crate::dataset::builder::DatasetBuilder;
use crate::dataset::{
MergeInsertBuilder, UpdateBuilder, WhenMatched, WhenNotMatched, WriteMode, WriteParams,
};
use crate::utils::test::{DatagenExt, FragmentCount, FragmentRowCount};
use arrow_array::RecordBatchIterator;
use arrow_array::types::Int32Type;
use arrow_schema::Schema as ArrowSchema;
use lance_table::format::{Fragment, RowIdMeta, pb};
use lance_table::rowids::version::RowDatasetVersionSequence;
use lance_table::rowids::write_row_ids;
use prost::Message;
use rstest::rstest;
fn encode_segments(segments: Vec<pb::u64_segment::Segment>) -> Vec<u8> {
pb::RowIdSequence {
segments: segments
.into_iter()
.map(|segment| pb::U64Segment {
segment: Some(segment),
})
.collect(),
}
.encode_to_vec()
}
fn range_segment(start: u64, end: u64) -> pb::u64_segment::Segment {
pb::u64_segment::Segment::Range(pb::u64_segment::Range { start, end })
}
fn empty_encoded_array() -> pb::EncodedU64Array {
pb::EncodedU64Array {
array: Some(pb::encoded_u64_array::Array::U64Array(
pb::encoded_u64_array::U64Array { values: Vec::new() },
)),
}
}
fn empty_array_segment() -> pb::u64_segment::Segment {
pb::u64_segment::Segment::Array(empty_encoded_array())
}
fn empty_sorted_array_segment() -> pb::u64_segment::Segment {
pb::u64_segment::Segment::SortedArray(empty_encoded_array())
}
fn empty_range_with_holes_segment() -> pb::u64_segment::Segment {
pb::u64_segment::Segment::RangeWithHoles(pb::u64_segment::RangeWithHoles {
start: 0,
end: 0,
holes: Some(empty_encoded_array()),
})
}
fn empty_range_with_bitmap_segment() -> pb::u64_segment::Segment {
pb::u64_segment::Segment::RangeWithBitmap(pb::u64_segment::RangeWithBitmap {
start: 0,
end: 0,
bitmap: Vec::new(),
})
}
#[tokio::test]
async fn test_validate_rejects_row_id_count_overflow() {
let temp_dir = lance_core::utils::tempfile::TempStrDir::default();
let mut dataset = validation_fixture(&temp_dir).await;
let encoded = encode_segments(vec![range_segment(0, u64::MAX), range_segment(0, u64::MAX)]);
edit_fragments(&mut dataset, |fragments| {
fragments[1].row_id_meta = Some(RowIdMeta::Inline(encoded.into()));
});
assert_invalid(&dataset, "total length exceeding u64::MAX").await;
}
#[rstest]
#[case::array(empty_array_segment())]
#[case::sorted_array(empty_sorted_array_segment())]
#[case::range_with_holes(empty_range_with_holes_segment())]
#[case::range_with_bitmap(empty_range_with_bitmap_segment())]
#[tokio::test]
async fn test_validate_tolerates_empty_segment(#[case] empty: pb::u64_segment::Segment) {
let temp_dir = lance_core::utils::tempfile::TempStrDir::default();
let mut dataset = validation_fixture(&temp_dir).await;
let encoded = encode_segments(vec![range_segment(10, 20), empty]);
edit_fragments(&mut dataset, |fragments| {
fragments[1].row_id_meta = Some(RowIdMeta::Inline(encoded.into()));
});
dataset.validate().await.unwrap();
}
async fn validation_fixture(uri: &str) -> Dataset {
lance_datagen::gen_batch()
.col("i", lance_datagen::array::step::<Int32Type>())
.into_dataset_with_params(
uri,
FragmentCount::from(2),
FragmentRowCount::from(10),
Some(WriteParams {
max_rows_per_file: 10,
enable_stable_row_ids: true,
..Default::default()
}),
)
.await
.unwrap();
DatasetBuilder::from_uri(uri).load().await.unwrap()
}
fn edit_fragments(dataset: &mut Dataset, edit: impl FnOnce(&mut Vec<Fragment>)) {
let mut manifest = dataset.manifest.as_ref().clone();
let mut fragments = manifest.fragments.as_ref().clone();
edit(&mut fragments);
manifest.fragments = Arc::new(fragments);
dataset.manifest = Arc::new(manifest);
}
async fn assert_invalid(dataset: &Dataset, expected_message: &str) {
let err = dataset.validate().await.unwrap_err();
assert!(
matches!(err, Error::CorruptFile { .. }),
"expected a corrupt file error, got {err:?}"
);
let message = err.to_string();
assert!(
message.contains(expected_message),
"expected {expected_message:?} in {message:?}"
);
}
#[tokio::test]
async fn test_validate_rejects_missing_row_id_meta() {
let temp_dir = lance_core::utils::tempfile::TempStrDir::default();
let mut dataset = validation_fixture(&temp_dir).await;
edit_fragments(&mut dataset, |fragments| fragments[1].row_id_meta = None);
assert_invalid(&dataset, "Fragment 1 has no row id metadata").await;
}
#[tokio::test]
async fn test_validate_rejects_row_id_sequence_length_mismatch() {
let temp_dir = lance_core::utils::tempfile::TempStrDir::default();
let mut dataset = validation_fixture(&temp_dir).await;
edit_fragments(&mut dataset, |fragments| {
let short = RowIdSequence::from(&[100u64, 101, 102][..]);
fragments[1].row_id_meta = Some(RowIdMeta::Inline(write_row_ids(&short).into()));
});
assert_invalid(&dataset, "Fragment 1 has 3 row ids, but 10 physical rows").await;
}
#[tokio::test]
async fn test_validate_rejects_duplicate_live_row_ids() {
let temp_dir = lance_core::utils::tempfile::TempStrDir::default();
let mut dataset = validation_fixture(&temp_dir).await;
edit_fragments(&mut dataset, |fragments| {
fragments[1].row_id_meta = fragments[0].row_id_meta.clone();
});
assert_invalid(
&dataset,
"Row id 0 is live in both fragment 0 at offset 0 and fragment 1 at offset 0",
)
.await;
}
#[tokio::test]
async fn test_validate_rejects_reused_next_row_id() {
let temp_dir = lance_core::utils::tempfile::TempStrDir::default();
let mut dataset = validation_fixture(&temp_dir).await;
let mut manifest = dataset.manifest.as_ref().clone();
manifest.next_row_id = 19;
dataset.manifest = Arc::new(manifest);
assert_invalid(
&dataset,
"will hand out row id 19 next, but row id 19 is already in use",
)
.await;
}
#[tokio::test]
async fn test_validate_rejects_misaligned_version_sequence() {
let temp_dir = lance_core::utils::tempfile::TempStrDir::default();
let mut dataset = validation_fixture(&temp_dir).await;
edit_fragments(&mut dataset, |fragments| {
let too_long = RowDatasetVersionSequence::from_uniform_row_count(11, 1);
fragments[1].created_at_version_meta =
Some(RowDatasetVersionMeta::from_sequence(&too_long).unwrap());
});
assert_invalid(
&dataset,
"Fragment 1 has 11 created_at versions, but 10 physical rows",
)
.await;
}
async fn count_repeated_row_ids(dataset: &Dataset) -> usize {
let mut seen = RoaringTreemap::new();
let mut repeated = 0;
for fragment in dataset.get_fragments() {
let sequence = load_row_id_sequence(dataset, fragment.metadata())
.await
.unwrap();
repeated += sequence.iter().filter(|id| !seen.insert(*id)).count();
}
repeated
}
#[tokio::test]
async fn test_validate_across_write_operations() {
let temp_dir = lance_core::utils::tempfile::TempStrDir::default();
let dataset = validation_fixture(&temp_dir).await;
dataset.validate().await.unwrap();
let batch = lance_datagen::gen_batch()
.col("i", lance_datagen::array::step_custom::<Int32Type>(20, 1))
.into_batch_rows(lance_datagen::RowCount::from(10))
.unwrap();
let arrow_schema = Arc::new(ArrowSchema::from(dataset.schema()));
let reader = RecordBatchIterator::new(vec![Ok(batch)], arrow_schema);
let mut dataset = Dataset::write(
reader,
&temp_dir,
Some(WriteParams {
mode: WriteMode::Append,
enable_stable_row_ids: true,
..Default::default()
}),
)
.await
.unwrap();
dataset.validate().await.unwrap();
delete(&mut dataset, "i = 4 or i = 12").await;
dataset.validate().await.unwrap();
let dataset = UpdateBuilder::new(Arc::new(dataset))
.update_where("i >= 15")
.unwrap()
.set("i", "i + 1000")
.unwrap()
.build()
.unwrap()
.execute()
.await
.unwrap()
.new_dataset;
dataset.validate().await.unwrap();
assert!(
count_repeated_row_ids(&dataset).await > 0,
"update should leave rewritten row ids behind in tombstoned slots"
);
let merge_source = lance_datagen::gen_batch()
.col("i", lance_datagen::array::step_custom::<Int32Type>(8, 1))
.into_batch_rows(lance_datagen::RowCount::from(6))
.unwrap();
let schema = merge_source.schema();
let merge_job = MergeInsertBuilder::try_new(dataset.clone(), vec!["i".to_string()])
.unwrap()
.when_matched(WhenMatched::UpdateAll)
.when_not_matched(WhenNotMatched::InsertAll)
.try_build()
.unwrap();
let source = lance_datafusion::utils::reader_to_stream(Box::new(RecordBatchIterator::new(
[Ok(merge_source)],
schema,
)));
let (dataset, _stats) = merge_job.execute(source).await.unwrap();
dataset.validate().await.unwrap();
let mut dataset = dataset.as_ref().clone();
compact(&mut dataset, 20).await;
dataset.validate().await.unwrap();
}
}