Skip to content

Commit 43c7f0b

Browse files
authored
fix: 2.1/2.2 panic when a list column had small values and many empty values (#6234)
If there are some non-null / non-empty values (so we can't use all-null branch) and they are small (so we use miniblock) and there are many repetition levels (can be caused by many empty / null lists) then we can get in a situation where we have too many rep/def levels for a chunk (exceed max chunk size). This PR detects that scenario and falls back to full-zip encoding in these cases (it's less performant decoding but these situations should be very rare or maybe in the future we can have different structure encodings for very sparse data) Closes #6184
1 parent 42581d4 commit 43c7f0b

2 files changed

Lines changed: 181 additions & 40 deletions

File tree

rust/lance-encoding/src/encodings/logical/list.rs

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -889,4 +889,55 @@ mod tests {
889889
// Actual: panic at primitive.rs:1362 - assertion failed: rows_avail > 0
890890
check_round_trip_encoding_of_data(vec![list_array], &test_cases, HashMap::new()).await;
891891
}
892+
893+
#[rstest]
894+
#[test_log::test(tokio::test)]
895+
async fn test_sparse_large_string_list(
896+
#[values(STRUCTURAL_ENCODING_MINIBLOCK, STRUCTURAL_ENCODING_FULLZIP)]
897+
structural_encoding: &str,
898+
) {
899+
// 2.5 million rows, mostly empty lists. ~100 lists have 10 short strings each.
900+
let num_rows = 2_500_000u32;
901+
let num_non_empty = 100u32;
902+
let strings_per_list = 10;
903+
904+
let items_builder = StringBuilder::new();
905+
let mut list_builder = ListBuilder::new(items_builder);
906+
907+
// Spread non-empty lists evenly across the range
908+
let step = num_rows / num_non_empty;
909+
let mut next_non_empty = step / 2;
910+
911+
for i in 0..num_rows {
912+
if i == next_non_empty {
913+
let vals: Vec<Option<&str>> = (0..strings_per_list)
914+
.map(|j| match j % 4 {
915+
0 => Some("a"),
916+
1 => Some("bb"),
917+
2 => Some("ccc"),
918+
_ => Some("d"),
919+
})
920+
.collect();
921+
list_builder.append_value(vals);
922+
next_non_empty = next_non_empty.saturating_add(step);
923+
} else {
924+
list_builder.append_value([] as [Option<&str>; 0]);
925+
}
926+
}
927+
let list_array = list_builder.finish();
928+
929+
let mut field_metadata = HashMap::new();
930+
field_metadata.insert(
931+
STRUCTURAL_ENCODING_META_KEY.to_string(),
932+
structural_encoding.into(),
933+
);
934+
935+
let test_cases = TestCases::default()
936+
.with_range(0..1000)
937+
.with_range(0..num_rows as u64)
938+
.with_indices(vec![0, (step / 2) as u64, num_rows as u64 - 1])
939+
.with_max_file_version(LanceFileVersion::V2_2);
940+
check_round_trip_encoding_of_data(vec![Arc::new(list_array)], &test_cases, field_metadata)
941+
.await;
942+
}
892943
}

rust/lance-encoding/src/encodings/logical/primitive.rs

Lines changed: 130 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -3737,6 +3737,66 @@ impl PrimitiveStructuralEncoder {
37373737
Self::is_narrow(data_block)
37383738
}
37393739

3740+
/// Checks if the rep/def levels are too sparse for miniblock encoding.
3741+
///
3742+
/// Miniblock chunks are limited to ~32KiB total. Data can use up to ~16KiB,
3743+
/// leaving ~16KiB for both rep and def buffers combined. Each chunk has at most
3744+
/// MAX_MINIBLOCK_VALUES (4096) data values, but when data has many empty/null
3745+
/// lists, the number of rep/def levels can far exceed the number of data values
3746+
/// (each empty list adds a level entry with no corresponding data value).
3747+
///
3748+
/// We estimate the compressed bits per level by computing the max value in each
3749+
/// buffer and taking ceil(log2(max_val + 1)) — the minimum bits needed to
3750+
/// bitpack each level. We then calculate the maximum number of levels that fit
3751+
/// in 16KiB and compare against the actual levels-to-values ratio.
3752+
fn repdef_too_sparse_for_miniblock(
3753+
repdef: &crate::repdef::SerializedRepDefs,
3754+
num_values: u64,
3755+
) -> bool {
3756+
if num_values == 0 {
3757+
return false;
3758+
}
3759+
let num_levels = repdef
3760+
.repetition_levels
3761+
.as_ref()
3762+
.map(|r| r.len() as u64)
3763+
.max(repdef.definition_levels.as_ref().map(|d| d.len() as u64))
3764+
.unwrap_or(0);
3765+
if num_levels == 0 {
3766+
return false;
3767+
}
3768+
3769+
// Compute bits needed per level for each buffer (ceil of log2(max+1))
3770+
let bits_per_rep = repdef
3771+
.repetition_levels
3772+
.as_ref()
3773+
.and_then(|r| r.iter().max().copied())
3774+
.map(|max_val| u16::BITS - max_val.leading_zeros())
3775+
.unwrap_or(0) as u64;
3776+
let bits_per_def = repdef
3777+
.definition_levels
3778+
.as_ref()
3779+
.and_then(|d| d.iter().max().copied())
3780+
.map(|max_val| u16::BITS - max_val.leading_zeros())
3781+
.unwrap_or(0) as u64;
3782+
3783+
let bits_per_level = bits_per_rep + bits_per_def;
3784+
if bits_per_level == 0 {
3785+
return false;
3786+
}
3787+
3788+
// 16KiB budget for rep+def combined (half the ~32KiB chunk limit)
3789+
const REPDEF_BUDGET_BITS: u64 = 16 * 1024 * 8;
3790+
let max_levels_per_chunk = REPDEF_BUDGET_BITS / bits_per_level;
3791+
3792+
// A chunk has at most MAX_MINIBLOCK_VALUES data values. The levels-to-values
3793+
// ratio tells us how many levels a chunk of that size would need.
3794+
let levels_per_chunk =
3795+
(num_levels as f64 / num_values as f64) * miniblock::MAX_MINIBLOCK_VALUES as f64;
3796+
3797+
levels_per_chunk > max_levels_per_chunk as f64
3798+
}
3799+
37403800
fn prefers_fullzip(encoding_metadata: &HashMap<String, String>) -> bool {
37413801
// Fullzip is the backup option so the only reason we wouldn't use it is if the
37423802
// user specifically requested not to use it (in which case we're probably going
@@ -3798,7 +3858,7 @@ impl PrimitiveStructuralEncoder {
37983858
rep: Option<Vec<CompressedLevelsChunk>>,
37993859
def: Option<Vec<CompressedLevelsChunk>>,
38003860
support_large_chunk: bool,
3801-
) -> SerializedMiniBlockPage {
3861+
) -> Result<SerializedMiniBlockPage> {
38023862
let bytes_rep = rep
38033863
.as_ref()
38043864
.map(|rep| rep.iter().map(|r| r.data.len()).sum::<usize>())
@@ -3842,11 +3902,21 @@ impl PrimitiveStructuralEncoder {
38423902

38433903
// Write the buffer lengths
38443904
if let Some(rep) = rep.as_ref() {
3845-
let bytes_rep = u16::try_from(rep.data.len()).unwrap();
3905+
let bytes_rep = u16::try_from(rep.data.len()).map_err(|_| {
3906+
Error::internal(format!(
3907+
"Repetition buffer size ({} bytes) too large",
3908+
rep.data.len()
3909+
))
3910+
})?;
38463911
data_buffer.extend_from_slice(&bytes_rep.to_le_bytes());
38473912
}
38483913
if let Some(def) = def.as_ref() {
3849-
let bytes_def = u16::try_from(def.data.len()).unwrap();
3914+
let bytes_def = u16::try_from(def.data.len()).map_err(|_| {
3915+
Error::internal(format!(
3916+
"Definition buffer size ({} bytes) too large",
3917+
def.data.len()
3918+
))
3919+
})?;
38503920
data_buffer.extend_from_slice(&bytes_def.to_le_bytes());
38513921
}
38523922

@@ -3916,11 +3986,11 @@ impl PrimitiveStructuralEncoder {
39163986
let data_buffer = LanceBuffer::from(data_buffer);
39173987
let metadata_buffer = LanceBuffer::from(meta_buffer);
39183988

3919-
SerializedMiniBlockPage {
3989+
Ok(SerializedMiniBlockPage {
39203990
num_buffers: miniblocks.data.len() as u64,
39213991
data: data_buffer,
39223992
metadata: metadata_buffer,
3923-
}
3993+
})
39243994
}
39253995

39263996
/// Compresses a buffer of levels into chunks
@@ -4448,7 +4518,7 @@ impl PrimitiveStructuralEncoder {
44484518
.map(|cd| std::mem::take(&mut cd.data));
44494519

44504520
let serialized =
4451-
Self::serialize_miniblocks(compressed_data, rep_data, def_data, support_large_chunk);
4521+
Self::serialize_miniblocks(compressed_data, rep_data, def_data, support_large_chunk)?;
44524522

44534523
// Metadata, Data, Dictionary, (maybe) Repetition Index
44544524
let mut data = Vec::with_capacity(4);
@@ -5112,41 +5182,61 @@ impl PrimitiveStructuralEncoder {
51125182
);
51135183
}
51145184

5115-
if let DataBlock::Dictionary(dict) = data_block {
5116-
log::debug!("Encoding column {} with {} items using dictionary encoding (already dictionary encoded)", column_idx, num_values);
5117-
let (mut indices_data_block, dictionary_data_block) = dict.into_parts();
5118-
// TODO: https://github.com/lancedb/lance/issues/4809
5119-
// If we compute stats on dictionary_data_block => panic.
5120-
// If we don't compute stats on indices_data_block => panic.
5121-
// This is messy. Don't make me call compute_stat ever.
5122-
indices_data_block.compute_stat();
5123-
Self::encode_miniblock(
5124-
column_idx,
5125-
&field,
5126-
compression_strategy.as_ref(),
5127-
indices_data_block,
5128-
repdef,
5129-
row_number,
5130-
Some(dictionary_data_block),
5131-
num_rows,
5132-
support_large_chunk,
5133-
)
5185+
// If the rep/def levels are too sparse for miniblock (e.g. many empty
5186+
// lists with very few values), fall back to fullzip to avoid exceeding
5187+
// the u16 per-chunk rep/def buffer size limit.
5188+
let too_sparse = Self::repdef_too_sparse_for_miniblock(&repdef, num_values);
5189+
5190+
if !too_sparse {
5191+
if let DataBlock::Dictionary(dict) = data_block {
5192+
log::debug!("Encoding column {} with {} items using dictionary encoding (already dictionary encoded)", column_idx, num_values);
5193+
let (mut indices_data_block, dictionary_data_block) = dict.into_parts();
5194+
// TODO: https://github.com/lancedb/lance/issues/4809
5195+
// If we compute stats on dictionary_data_block => panic.
5196+
// If we don't compute stats on indices_data_block => panic.
5197+
// This is messy. Don't make me call compute_stat ever.
5198+
indices_data_block.compute_stat();
5199+
return Self::encode_miniblock(
5200+
column_idx,
5201+
&field,
5202+
compression_strategy.as_ref(),
5203+
indices_data_block,
5204+
repdef,
5205+
row_number,
5206+
Some(dictionary_data_block),
5207+
num_rows,
5208+
support_large_chunk,
5209+
);
5210+
}
51345211
} else {
5212+
log::debug!(
5213+
"Encoding column {} with {} items using full-zip layout \
5214+
(rep/def too sparse for mini-block)",
5215+
column_idx,
5216+
num_values
5217+
);
5218+
}
5219+
5220+
{
51355221
// Try dictionary encoding first if applicable. If encoding aborts, fall back to the
51365222
// preferred structural encoding.
5137-
let dict_result = Self::should_dictionary_encode(&data_block, &field, version)
5138-
.and_then(|budget| {
5139-
log::debug!(
5140-
"Encoding column {} with {} items using dictionary encoding (mini-block layout)",
5141-
column_idx,
5142-
num_values
5143-
);
5144-
dict::dictionary_encode(
5145-
&data_block,
5146-
budget.max_dict_entries,
5147-
budget.max_encoded_size,
5148-
)
5149-
});
5223+
let dict_result = if too_sparse {
5224+
None
5225+
} else {
5226+
Self::should_dictionary_encode(&data_block, &field, version)
5227+
.and_then(|budget| {
5228+
log::debug!(
5229+
"Encoding column {} with {} items using dictionary encoding (mini-block layout)",
5230+
column_idx,
5231+
num_values
5232+
);
5233+
dict::dictionary_encode(
5234+
&data_block,
5235+
budget.max_dict_entries,
5236+
budget.max_encoded_size,
5237+
)
5238+
})
5239+
};
51505240

51515241
if let Some((indices_data_block, dictionary_data_block)) = dict_result {
51525242
Self::encode_miniblock(
@@ -5160,7 +5250,7 @@ impl PrimitiveStructuralEncoder {
51605250
num_rows,
51615251
support_large_chunk,
51625252
)
5163-
} else if Self::prefers_miniblock(&data_block, encoding_metadata.as_ref()) {
5253+
} else if !too_sparse && Self::prefers_miniblock(&data_block, encoding_metadata.as_ref()) {
51645254
log::debug!(
51655255
"Encoding column {} with {} items using mini-block layout",
51665256
column_idx,
@@ -5177,7 +5267,7 @@ impl PrimitiveStructuralEncoder {
51775267
num_rows,
51785268
support_large_chunk,
51795269
)
5180-
} else if Self::prefers_fullzip(encoding_metadata.as_ref()) {
5270+
} else if too_sparse || Self::prefers_fullzip(encoding_metadata.as_ref()) {
51815271
log::debug!(
51825272
"Encoding column {} with {} items using full-zip layout",
51835273
column_idx,

0 commit comments

Comments
 (0)