This is an automated email from the ASF dual-hosted git repository.
JingsongLi pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/paimon-rust.git
The following commit(s) were added to refs/heads/main by this push:
new ff7b9390 fix(vindex): bound a deletion-vector position by its own
source file (#802)
ff7b9390 is described below
commit ff7b93902453410152590dbb555b046965884bac
Author: Junrui Lee <[email protected]>
AuthorDate: Thu Sep 10 15:13:27 2026 +0800
fix(vindex): bound a deletion-vector position by its own source file (#802)
---
crates/paimon/src/vindex/pkvector/ann.rs | 44 ++++++++++++++++++++++++++++++++
1 file changed, 44 insertions(+)
diff --git a/crates/paimon/src/vindex/pkvector/ann.rs
b/crates/paimon/src/vindex/pkvector/ann.rs
index e2be9f87..80dd1082 100644
--- a/crates/paimon/src/vindex/pkvector/ann.rs
+++ b/crates/paimon/src/vindex/pkvector/ann.rs
@@ -121,6 +121,18 @@ pub(crate) fn build_live_row_ids(
if active {
if let Some(dv) = deletion_vectors.get(source_file.file_name()) {
for position in dv.iter() {
+ // Bound the position against ITS OWN source file, as the
residual
+ // path above already does. Source files share one ordinal
space
+ // (`global = file_offset + position`), so a position past
this
+ // file's rows does not fall out of the mask -- it lands
inside the
+ // NEXT source file's range and deletes one of that file's
rows.
+ if position >= row_count {
+ return Err(data_invalid(format!(
+ "deleted position {position} is out of range for
source file {} ({} rows)",
+ source_file.file_name(),
+ row_count
+ )));
+ }
let global =
file_offset.checked_add(position).ok_or_else(|| {
data_invalid("vector source deleted position overflows
u64")
})?;
@@ -891,6 +903,38 @@ mod tests {
assert_eq!(live.iter().collect::<Vec<u64>>(), vec![0, 2]);
}
+ #[test]
+ fn a_deletion_vector_position_past_its_own_file_is_refused() {
+ // f0 holds 3 rows (global 0,1,2) and f1 holds 2 (global 3,4). A
deletion
+ // vector on f0 naming position 3 has no row of f0 to delete; without
the
+ // bound it becomes global 0 + 3 = 3, which is f1's FIRST row. The
mask would
+ // come back well-formed, having silently dropped a row of a different
file.
+ let files = vec![
+ PkVectorSourceFile::new("f0".into(), 3).unwrap(),
+ PkVectorSourceFile::new("f1".into(), 2).unwrap(),
+ ];
+ let mut dvs = HashMap::new();
+ dvs.insert("f0".to_string(), dv(&[3]));
+ let error = build_live_row_ids(&files, &active_set(&["f0", "f1"]),
&dvs, None)
+ .map(|_| ())
+ .expect_err("a position past f0's rows names no row of f0");
+ assert!(
+ error
+ .to_string()
+ .contains("out of range for source file f0"),
+ "{error}"
+ );
+
+ // The last VALID position of f0 still deletes f0's own row, and f1 is
+ // untouched -- the bound is `>= row_count`, not one row tighter.
+ let mut dvs = HashMap::new();
+ dvs.insert("f0".to_string(), dv(&[2]));
+ let live = build_live_row_ids(&files, &active_set(&["f0", "f1"]),
&dvs, None)
+ .unwrap()
+ .unwrap();
+ assert_eq!(live.iter().collect::<Vec<u64>>(), vec![0, 1, 3, 4]);
+ }
+
#[test]
fn test_build_live_row_ids_residual_maps_positions_across_file_offsets() {
// f0 rows global 0,1,2; f1 rows global 3,4. residual allows f0={2},
f1={1}.