use super::*;
#[tokio::test]
async fn test_posting_cache_conflict_across_partitions() {
let tmpdir = TempObjDir::default();
let store = Arc::new(LanceIndexStore::new(
ObjectStore::local().into(),
tmpdir.clone(),
Arc::new(LanceCache::no_cache()),
));
let mut builder1 = InnerBuilder::new(0, false, TokenSetFormat::default());
builder1.tokens.add("test".to_owned());
builder1.posting_lists.push(PostingListBuilder::new(false));
builder1.posting_lists[0].add(0, PositionRecorder::Count(1));
builder1.docs.append(100, 1); builder1.write(store.as_ref()).await.unwrap();
let mut builder2 = InnerBuilder::new(1, false, TokenSetFormat::default());
builder2.tokens.add("test".to_owned()); builder2.posting_lists.push(PostingListBuilder::new(false));
builder2.posting_lists[0].add(0, PositionRecorder::Count(2));
builder2.posting_lists[0].add(1, PositionRecorder::Count(1));
builder2.posting_lists[0].add(2, PositionRecorder::Count(3));
builder2.posting_lists[0].add(3, PositionRecorder::Count(1));
builder2.docs.append(200, 2); builder2.docs.append(201, 1); builder2.docs.append(202, 3); builder2.docs.append(203, 1); builder2.write(store.as_ref()).await.unwrap();
let metadata = std::collections::HashMap::from_iter(vec![
(
"partitions".to_owned(),
serde_json::to_string(&vec![0u64, 1u64]).unwrap(),
),
(
"params".to_owned(),
serde_json::to_string(&InvertedIndexParams::default()).unwrap(),
),
(
TOKEN_SET_FORMAT_KEY.to_owned(),
TokenSetFormat::default().to_string(),
),
]);
let mut writer = store
.new_index_file(METADATA_FILE, Arc::new(arrow_schema::Schema::empty()))
.await
.unwrap();
writer.finish_with_metadata(metadata).await.unwrap();
let cache = Arc::new(LanceCache::with_capacity(4096));
let index = InvertedIndex::load(store.clone(), None, cache.as_ref())
.await
.unwrap();
assert_eq!(index.partitions.len(), 2);
assert_eq!(index.partitions[0].tokens.len(), 1);
assert_eq!(index.partitions[1].tokens.len(), 1);
let pl_0_0 = index.partitions[0]
.inverted_list
.posting_len_for_token(0, None)
.await
.unwrap();
let pl_1_0 = index.partitions[1]
.inverted_list
.posting_len_for_token(0, None)
.await
.unwrap();
if index.partitions[0].id() == 0 {
assert_eq!(pl_0_0, 1);
assert_eq!(pl_1_0, 4);
assert_eq!(index.partitions[0].docs.len(), 1);
assert_eq!(index.partitions[1].docs.len(), 4);
} else {
assert_eq!(pl_0_0, 4);
assert_eq!(pl_1_0, 1);
assert_eq!(index.partitions[0].docs.len(), 4);
assert_eq!(index.partitions[1].docs.len(), 1);
}
index.prewarm().await.unwrap();
let tokens = Arc::new(Tokens::new(vec!["test".to_string()], DocType::Text));
let params = Arc::new(FtsSearchParams::new().with_limit(Some(10)));
let prefilter = Arc::new(NoFilter);
let metrics = Arc::new(NoOpMetricsCollector);
let (row_ids, scores) = index
.bm25_search(tokens, params, Operator::Or, prefilter, metrics, None)
.await
.unwrap();
assert_eq!(row_ids.len(), 5, "row_ids: {:?}", row_ids);
assert!(!row_ids.is_empty(), "Should find at least some documents");
assert_eq!(row_ids.len(), scores.len());
for &score in &scores {
assert!(score > 0.0, "All scores should be positive");
}
assert!(
row_ids.contains(&100),
"Should contain row_id from partition 0"
);
assert!(
row_ids.iter().any(|&id| id >= 200),
"Should contain row_id from partition 1"
);
}
#[tokio::test]
async fn test_modern_prewarm_packs_group_with_shared_posting_buffer() {
let tmpdir = TempObjDir::default();
let store = Arc::new(LanceIndexStore::new(
ObjectStore::local().into(),
tmpdir.clone(),
Arc::new(LanceCache::no_cache()),
));
let mut builder = InnerBuilder::new(0, false, TokenSetFormat::default());
builder.tokens.add("alpha".to_owned());
builder.tokens.add("beta".to_owned());
builder.posting_lists.push(PostingListBuilder::new(false));
builder.posting_lists.push(PostingListBuilder::new(false));
builder.posting_lists[0].add(0, PositionRecorder::Count(1));
builder.posting_lists[0].add(1, PositionRecorder::Count(2));
builder.posting_lists[1].add(2, PositionRecorder::Count(3));
builder.posting_lists[1].add(3, PositionRecorder::Count(4));
builder.docs.append(100, 1);
builder.docs.append(101, 2);
builder.docs.append(102, 3);
builder.docs.append(103, 4);
builder.write(store.as_ref()).await.unwrap();
let metadata = std::collections::HashMap::from_iter(vec![
(
"partitions".to_owned(),
serde_json::to_string(&vec![0u64]).unwrap(),
),
(
"params".to_owned(),
serde_json::to_string(&InvertedIndexParams::default()).unwrap(),
),
(
TOKEN_SET_FORMAT_KEY.to_owned(),
TokenSetFormat::default().to_string(),
),
]);
let mut writer = store
.new_index_file(METADATA_FILE, Arc::new(arrow_schema::Schema::empty()))
.await
.unwrap();
writer.finish_with_metadata(metadata).await.unwrap();
let cache = Arc::new(LanceCache::with_capacity(4096));
let index = InvertedIndex::load(store.clone(), None, cache.as_ref())
.await
.unwrap();
let inverted_list = &index.partitions[0].inverted_list;
assert!(
!inverted_list.is_legacy_layout(),
"test should use modern posting layout"
);
assert!(
inverted_list.has_impacts,
"modern posting fixture should include impact skip data"
);
inverted_list.prewarm_posting_lists(false, 2).await.unwrap();
let (start, end) = inverted_list.group_range_for_token(0).unwrap();
let group = inverted_list
.index_cache
.get_with_key(&posting_list_group_cache_key(
start,
end,
inverted_list.has_impacts,
))
.await
.unwrap();
assert!(
group.is_packed(),
"no-position prewarm should pack v2 groups"
);
assert!(
group.needs_external_metadata(),
"prewarmed packed groups must not duplicate reader score/length metadata"
);
let (alpha_score, alpha_len) = inverted_list.bulk_metadata_for_token(0);
let PostingList::Compressed(alpha) = group
.posting_list(0, alpha_score, alpha_len)
.unwrap()
.unwrap()
else {
panic!("expected compressed posting list for token 0");
};
let PostingList::Compressed(alpha_again) = group
.posting_list(0, alpha_score, alpha_len)
.unwrap()
.unwrap()
else {
panic!("expected compressed posting list for repeated token 0 access");
};
let (beta_score, beta_len) = inverted_list.bulk_metadata_for_token(1);
let PostingList::Compressed(beta) = group
.posting_list(1, beta_score, beta_len)
.unwrap()
.unwrap()
else {
panic!("expected compressed posting list for token 1");
};
assert!(
alpha.impacts.is_some() && beta.impacts.is_some(),
"packed prewarm must preserve impact skip data"
);
assert!(
alpha
.impacts
.as_ref()
.unwrap()
.shares_derived_state_with(alpha_again.impacts.as_ref().unwrap()),
"repeated packed slot access must share decoded impact state"
);
assert!(
alpha.shares_first_docs_with(&alpha_again),
"repeated packed slot access must share decoded block heads"
);
assert_eq!(
alpha.block_first_docs().as_ptr(),
alpha_again.block_first_docs().as_ptr(),
"packed block heads should be decoded only once per slot"
);
assert_eq!(
alpha.blocks.values().as_ptr(),
beta.blocks.values().as_ptr(),
"packed posting views should share the group's values buffer"
);
}
#[tokio::test]
async fn test_packed_prewarm_groups_do_not_retain_the_full_chunk() {
let tmpdir = TempObjDir::default();
let store = Arc::new(LanceIndexStore::new(
ObjectStore::local().into(),
tmpdir.clone(),
Arc::new(LanceCache::no_cache()),
));
let mut builder = InnerBuilder::new(0, false, TokenSetFormat::default());
for token_id in 0..4u32 {
builder.tokens.add(format!("t{token_id}"));
let mut posting = PostingListBuilder::new(false);
posting.add(token_id, PositionRecorder::Count(1));
builder.posting_lists.push(posting);
builder.docs.append(1000 + token_id as u64, 1);
}
builder.write(store.as_ref()).await.unwrap();
let reader = store.open_index_file(&posting_file_path(0)).await.unwrap();
let cache = LanceCache::with_capacity(1 << 20);
let mut posting_reader = PostingListReader::try_new(reader, &cache).await.unwrap();
posting_reader.grouping = PostingGrouping::SyntheticFixed { group_size: 2 };
assert_eq!(
posting_reader
.prewarm_posting_lists_chunked(false, Some(4), 1)
.await
.unwrap(),
1,
"the test must read both groups in one prewarm chunk"
);
let first_group = posting_reader
.index_cache
.get_with_key(&posting_list_group_cache_key(
0,
2,
posting_reader.has_impacts,
))
.await
.unwrap();
let second_group = posting_reader
.index_cache
.get_with_key(&posting_list_group_cache_key(
2,
4,
posting_reader.has_impacts,
))
.await
.unwrap();
let (first_score, first_len) = posting_reader.bulk_metadata_for_token(0);
let PostingList::Compressed(first) = first_group
.posting_list(0, first_score, first_len)
.unwrap()
.unwrap()
else {
panic!("expected compressed posting list in first group");
};
let (neighbor_score, neighbor_len) = posting_reader.bulk_metadata_for_token(1);
let PostingList::Compressed(first_neighbor) = first_group
.posting_list(1, neighbor_score, neighbor_len)
.unwrap()
.unwrap()
else {
panic!("expected compressed posting list in first group");
};
let (second_score, second_len) = posting_reader.bulk_metadata_for_token(2);
let PostingList::Compressed(second) = second_group
.posting_list(0, second_score, second_len)
.unwrap()
.unwrap()
else {
panic!("expected compressed posting list in second group");
};
assert_eq!(
first.blocks.values().as_ptr(),
first_neighbor.blocks.values().as_ptr(),
"postings in one group should share the group's values buffer"
);
assert_ne!(
first.blocks.values().as_ptr(),
second.blocks.values().as_ptr(),
"each group must own a compact buffer instead of retaining the full chunk"
);
}
#[test]
fn test_prewarm_chunk_ranges_preserve_group_boundaries() {
let grouping = PostingGrouping::SyntheticFixed { group_size: 4 };
assert_eq!(
prewarm_chunk_ranges(&grouping, 13, 5),
vec![(0, 4), (4, 8), (8, 13)],
"grouped chunks may contain multiple groups but must never split one"
);
assert_eq!(
prewarm_chunk_ranges(&PostingGrouping::None, 13, 5),
vec![(0, 5), (5, 10), (10, 13)],
"ungrouped chunk ranges should use plain token ranges"
);
}
#[test]
fn test_synthetic_grouping_preserves_fixed_boundaries() {
let grouping = PostingGrouping::SyntheticFixed { group_size: 4 };
assert_eq!(
grouping.range_for_token(5, 10),
Some((4, 8)),
"synthetic token groups should be fixed-size ranges"
);
assert_eq!(
grouping.range_for_token(9, 10),
Some((8, 10)),
"the final synthetic group should end at token_count"
);
assert_eq!(
prewarm_chunk_ranges(&grouping, 10, 6),
vec![(0, 4), (4, 10)],
"prewarm chunks may contain multiple synthetic groups but must not split one"
);
assert_eq!(
grouping.ranges_for_chunk(4, 10, 10),
vec![(4, 8), (8, 10)],
"publish selection should enumerate synthetic groups in a chunk"
);
}
#[rstest::rstest]
#[case::v1(InvertedListFormatVersion::V1, LEGACY_BLOCK_SIZE)]
#[case::v2(InvertedListFormatVersion::V2, LEGACY_BLOCK_SIZE)]
#[case::v3(InvertedListFormatVersion::V3, 256)]
#[tokio::test]
async fn test_prewarm_streams_in_chunks_preserves_content(
#[case] format_version: InvertedListFormatVersion,
#[case] block_size: usize,
) {
let tmpdir = TempObjDir::default();
let store = Arc::new(LanceIndexStore::new(
ObjectStore::local().into(),
tmpdir.clone(),
Arc::new(LanceCache::no_cache()),
));
let num_tokens = runtime_posting_group_tokens() as u32 + 4;
const DOCS_PER_TOKEN: u32 = 3;
let posting_tail_codec = format_version.posting_tail_codec();
let mut builder = InnerBuilder::new_with_format_version_and_block_size(
0,
false,
TokenSetFormat::default(),
format_version,
block_size,
);
let mut expected: Vec<Vec<(u32, u32)>> = Vec::new();
let mut doc_id = 0u64;
for t in 0..num_tokens {
builder.tokens.add(format!("tok_{t:03}"));
let mut posting = PostingListBuilder::new_with_posting_tail_codec_and_block_size(
false,
posting_tail_codec,
block_size,
);
let mut docs = Vec::new();
for _ in 0..DOCS_PER_TOKEN {
posting.add(doc_id as u32, PositionRecorder::Count(1));
builder.docs.append(doc_id, 1);
docs.push((doc_id as u32, 1));
doc_id += 1;
}
expected.push(docs);
builder.posting_lists.push(posting);
}
builder.write(store.as_ref()).await.unwrap();
let params = InvertedIndexParams::default()
.block_size(block_size)
.unwrap();
let metadata = std::collections::HashMap::from_iter(vec![
(
"partitions".to_owned(),
serde_json::to_string(&vec![0u64]).unwrap(),
),
("params".to_owned(), serde_json::to_string(¶ms).unwrap()),
(
TOKEN_SET_FORMAT_KEY.to_owned(),
TokenSetFormat::default().to_string(),
),
(
POSTING_TAIL_CODEC_KEY.to_owned(),
posting_tail_codec.as_str().to_owned(),
),
(
FTS_FORMAT_VERSION_KEY.to_owned(),
format_version.index_version().to_string(),
),
(POSTING_BLOCK_SIZE_KEY.to_owned(), block_size.to_string()),
]);
let mut writer = store
.new_index_file(METADATA_FILE, Arc::new(arrow_schema::Schema::empty()))
.await
.unwrap();
writer.finish_with_metadata(metadata).await.unwrap();
let cache = Arc::new(LanceCache::with_capacity(1 << 20));
let index = InvertedIndex::load(store.clone(), None, cache.as_ref())
.await
.unwrap();
let inverted_list = &index.partitions[0].inverted_list;
assert_eq!(inverted_list.len(), num_tokens as usize);
assert_eq!(inverted_list.block_size(), block_size);
const CHUNK_TOKENS: usize = 6;
let chunk_count = inverted_list
.prewarm_posting_lists_chunked(false, Some(CHUNK_TOKENS), 2)
.await
.unwrap();
assert!(
chunk_count > 1,
"single partition must be streamed in more than one chunk, got {chunk_count}"
);
if block_size == 256 {
let (start, end) = inverted_list.group_range_for_token(0).unwrap();
let group = inverted_list
.index_cache
.get_with_key(&posting_list_group_cache_key(
start,
end,
inverted_list.has_impacts,
))
.await
.expect("256-document blocks should populate the packed group cache");
assert!(group.is_packed());
let (max_score, length) = inverted_list.bulk_metadata_for_token(0);
let PostingList::Compressed(posting) =
group.posting_list(0, max_score, length).unwrap().unwrap()
else {
panic!("expected compressed posting list");
};
assert_eq!(posting.block_size, 256);
assert!(
posting.impacts.is_some(),
"packed prewarm must preserve impact skip data"
);
}
for token_id in 0..num_tokens {
let actual = inverted_list
.posting_list(token_id, false, &NoOpMetricsCollector)
.await
.unwrap()
.iter()
.map(|(doc_id, freq, _positions)| (doc_id as u32, freq))
.collect::<Vec<_>>();
assert_eq!(
actual, expected[token_id as usize],
"token {token_id} posting list mismatch after chunked prewarm"
);
}
}
#[tokio::test]
async fn test_prewarm_streams_in_chunks_with_positions() {
let tmpdir = TempObjDir::default();
let store = Arc::new(LanceIndexStore::new(
ObjectStore::local().into(),
tmpdir.clone(),
Arc::new(LanceCache::no_cache()),
));
let format_version = InvertedListFormatVersion::V2;
let posting_tail_codec = format_version.posting_tail_codec();
let num_tokens = runtime_posting_group_tokens() as u32 + 4;
const DOCS_PER_TOKEN: u32 = 3;
let mut builder =
InnerBuilder::new_with_format_version(0, true, TokenSetFormat::default(), format_version);
let mut expected: Vec<Vec<(u32, u32, Vec<u32>)>> = Vec::new();
let mut doc_id = 0u64;
for t in 0..num_tokens {
builder.tokens.add(format!("tok_{t:03}"));
let mut posting = PostingListBuilder::new_with_posting_tail_codec(true, posting_tail_codec);
let mut docs = Vec::new();
for _ in 0..DOCS_PER_TOKEN {
let positions = vec![t % 3, t % 3 + 2, t % 3 + 5];
posting.add(
doc_id as u32,
PositionRecorder::Position(positions.clone().into()),
);
builder.docs.append(doc_id, positions.len() as u32);
docs.push((doc_id as u32, positions.len() as u32, positions));
doc_id += 1;
}
expected.push(docs);
builder.posting_lists.push(posting);
}
builder.write(store.as_ref()).await.unwrap();
let metadata = std::collections::HashMap::from_iter(vec![
(
"partitions".to_owned(),
serde_json::to_string(&vec![0u64]).unwrap(),
),
(
"params".to_owned(),
serde_json::to_string(&InvertedIndexParams::default().with_position(true)).unwrap(),
),
(
TOKEN_SET_FORMAT_KEY.to_owned(),
TokenSetFormat::default().to_string(),
),
(
POSTING_TAIL_CODEC_KEY.to_owned(),
posting_tail_codec.as_str().to_owned(),
),
(
POSITIONS_LAYOUT_KEY.to_owned(),
POSITIONS_LAYOUT_SHARED_STREAM_V2.to_owned(),
),
(
POSITIONS_CODEC_KEY.to_owned(),
PositionStreamCodec::PackedDelta.as_str().to_owned(),
),
]);
let mut writer = store
.new_index_file(METADATA_FILE, Arc::new(arrow_schema::Schema::empty()))
.await
.unwrap();
writer.finish_with_metadata(metadata).await.unwrap();
let cache = Arc::new(LanceCache::with_capacity(1 << 20));
let index = InvertedIndex::load(store.clone(), None, cache.as_ref())
.await
.unwrap();
let inverted_list = &index.partitions[0].inverted_list;
const CHUNK_TOKENS: usize = 5;
let chunk_count = inverted_list
.prewarm_posting_lists_chunked(true, Some(CHUNK_TOKENS), 2)
.await
.unwrap();
assert!(
chunk_count > 1,
"partition must be streamed in more than one chunk, got {chunk_count}"
);
for token_id in 0..num_tokens {
let (start, end) = inverted_list.group_range_for_token(token_id).unwrap();
let group = inverted_list
.index_cache
.get_with_key(&posting_list_group_cache_key(
start,
end,
inverted_list.has_impacts,
))
.await
.unwrap();
let slot = (token_id - start) as usize;
assert!(
!group.is_packed(),
"with-position prewarm should retain the materialized fallback"
);
assert!(
!group
.posting_list(slot, None, None)
.unwrap()
.unwrap()
.has_position(),
"token {token_id} posting cache entry must be positions-free after prewarm"
);
let actual = inverted_list
.posting_list(token_id, true, &NoOpMetricsCollector)
.await
.unwrap()
.iter()
.map(|(doc_id, freq, positions)| {
(doc_id as u32, freq, positions.unwrap().collect::<Vec<_>>())
})
.collect::<Vec<_>>();
assert_eq!(
actual, expected[token_id as usize],
"token {token_id} posting list / positions mismatch after chunked prewarm"
);
}
}
#[tokio::test]
async fn test_strict_modern_prewarm_fails_when_index_cache_cannot_hold_all_partitions() {
let tmpdir = TempObjDir::default();
let store = Arc::new(LanceIndexStore::new(
ObjectStore::local().into(),
tmpdir.clone(),
Arc::new(LanceCache::no_cache()),
));
let params = InvertedIndexParams::default().format_version(InvertedListFormatVersion::V2);
let format_version = params.resolved_format_version();
let partition_count = 6_u64;
let docs_per_partition = 512_u32;
for partition_id in 0..partition_count {
let mut builder = InnerBuilder::new_with_format_version_and_block_size(
partition_id,
false,
TokenSetFormat::default(),
format_version,
params.posting_block_size(),
);
builder.tokens.add(format!("token_{partition_id}"));
let mut posting = PostingListBuilder::new_with_posting_tail_codec_and_block_size(
false,
format_version.posting_tail_codec(),
params.posting_block_size(),
);
for doc_id in 0..docs_per_partition {
posting.add(doc_id, PositionRecorder::Count(1));
builder
.docs
.append(partition_id * 1_000_000 + doc_id as u64, 1);
}
builder.posting_lists.push(posting);
builder.write(store.as_ref()).await.unwrap();
}
write_test_metadata(&store, (0..partition_count).collect(), params).await;
let cache = Arc::new(LanceCache::with_backend(Arc::new(
QuickCacheBackend::with_capacity(8 * 1024),
)));
let index = InvertedIndex::load(store, None, cache.as_ref())
.await
.unwrap();
let err = index
.prewarm_with_options(&FtsPrewarmOptions::default())
.await
.unwrap_err();
let err = err.to_string();
assert!(err.contains("partition(s) are not fully resident"));
assert!(err.contains("resident row-address projection"));
assert!(
index
.partitions
.iter()
.any(|partition| !partition.docs.query_ready()),
"strict prewarm should fail because at least one partition lost query-ready state"
);
assert!(
index
.partitions
.iter()
.all(|partition| partition.inverted_list.modern_posting_validation_ready()),
"posting validation should complete; the strict failure should be the final residency postcondition"
);
assert!(
index.partitions.iter().any(|partition| {
let docs = partition.docs.modern().unwrap();
!docs.projection_resident()
}),
"at least one modern document projection should be non-resident"
);
let result = index
.prewarm_with_options_result(&FtsPrewarmOptions::default().best_effort())
.await
.unwrap();
assert!(!result.fully_resident);
let diagnostics = result
.diagnostics
.expect("best-effort partial prewarm should report diagnostics");
assert_eq!(diagnostics.partition_count, partition_count as usize);
assert!(!diagnostics.failing_partitions.is_empty());
assert!(
diagnostics
.failing_partitions
.iter()
.all(|partition| partition.posting_validation_ready),
"best-effort should not suppress posting validation failures"
);
}