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 120ff9ec feat(table): plan a PK-vector search from a decoded bucket
split (#757)
120ff9ec is described below
commit 120ff9ec27a994bbdf4b0de1f176f10b6ed5d1c2
Author: Junrui Lee <[email protected]>
AuthorDate: Tue Sep 1 20:38:14 2026 +0800
feat(table): plan a PK-vector search from a decoded bucket split (#757)
---
crates/paimon/src/arrow/format/mosaic.rs | 58 ++
crates/paimon/src/table/data_file_reader.rs | 40 +-
crates/paimon/src/table/pk_vector_bucket_split.rs | 89 +++
.../paimon/src/table/pk_vector_data_file_reader.rs | 220 +++++--
crates/paimon/src/table/pk_vector_scan.rs | 675 ++++++++++++++++++++-
crates/paimon/src/table/vector_search_builder.rs | 526 ++++++++++++++--
crates/paimon/src/vindex/pkvector/ann.rs | 72 ++-
7 files changed, 1579 insertions(+), 101 deletions(-)
diff --git a/crates/paimon/src/arrow/format/mosaic.rs
b/crates/paimon/src/arrow/format/mosaic.rs
index 1fdbbace..b10c756c 100644
--- a/crates/paimon/src/arrow/format/mosaic.rs
+++ b/crates/paimon/src/arrow/format/mosaic.rs
@@ -877,6 +877,33 @@ mod tests {
.await
}
+ /// The byte ranges a read of `data` requests when limited to
`row_selection`.
+ async fn read_ranges_with_row_selection(
+ data: Bytes,
+ read_fields: &[DataField],
+ row_selection: Option<Vec<RowRange>>,
+ ) -> crate::Result<Vec<Range<u64>>> {
+ let file_size = data.len() as u64;
+ let calls = Arc::new(Mutex::new(Vec::new()));
+ let _: Vec<RecordBatch> = MosaicFormatReader
+ .read_batch_stream(
+ Box::new(TrackingFileRead {
+ data,
+ calls: Arc::clone(&calls),
+ }),
+ file_size,
+ read_fields,
+ None,
+ None,
+ row_selection,
+ )
+ .await?
+ .try_collect()
+ .await?;
+ let ranges = calls.lock().unwrap().clone();
+ Ok(ranges)
+ }
+
async fn read_ranges_with_predicates(
data: Bytes,
read_fields: &[DataField],
@@ -1298,6 +1325,37 @@ mod tests {
);
}
+ #[tokio::test]
+ async fn test_row_selection_skips_unselected_row_group_reads() {
+ // Three row groups of two rows. A selection inside the last one must
not
+ // fetch the column data of the first two: this is what makes a narrow
+ // engine-supplied row range cheaper than reading the file and
discarding
+ // rows afterwards, and it is granular to a row group, not to a row.
+ let fields = data_fields();
+ let projected = vec![fields[0].clone()];
+ let data = multi_row_group_mosaic(vec!["id".to_string()]);
+
+ let all = read_ranges_with_row_selection(data.clone(), &projected,
None)
+ .await
+ .unwrap();
+ let last_only =
+ read_ranges_with_row_selection(data, &projected,
Some(vec![RowRange::new(4, 5)]))
+ .await
+ .unwrap();
+
+ assert!(
+ last_only.len() < all.len(),
+ "a selection in one row group must request fewer ranges than a
full read: \
+ {last_only:?} vs {all:?}"
+ );
+ let selected_bytes: u64 = last_only.iter().map(|r| r.end -
r.start).sum();
+ let all_bytes: u64 = all.iter().map(|r| r.end - r.start).sum();
+ assert!(
+ selected_bytes < all_bytes,
+ "and fewer bytes: {selected_bytes} vs {all_bytes}"
+ );
+ }
+
#[tokio::test]
async fn test_read_predicate_missing_stats_still_filters_rows() {
let fields = data_fields();
diff --git a/crates/paimon/src/table/data_file_reader.rs
b/crates/paimon/src/table/data_file_reader.rs
index 25e20549..8958b382 100644
--- a/crates/paimon/src/table/data_file_reader.rs
+++ b/crates/paimon/src/table/data_file_reader.rs
@@ -599,6 +599,36 @@ impl DataFileReader {
data_fields: Option<Vec<DataField>>,
dv: Option<Arc<DeletionVector>>,
local_positions: Vec<i64>,
+ ) -> crate::Result<ArrowRecordBatchStream> {
+ self.read_single_file_stream_local_ranges(
+ split,
+ file_meta,
+ data_fields,
+ dv,
+ coalesce_positions_to_local_ranges(&local_positions),
+ )
+ }
+
+ /// As [`Self::read_single_file_stream_local`], but the selection is
already a
+ /// list of file-local inclusive ranges: sorted ascending,
non-overlapping, and
+ /// within `[0, file_meta.row_count)`. A caller that already holds ranges
— an
+ /// engine-supplied bucket split does — hands them over directly rather
than
+ /// expanding them into positions this would only coalesce back.
+ ///
+ /// The emitted rows are always exactly the selected ones, but what that
saves is
+ /// the format's business, and it differs: mosaic skips a row group before
+ /// touching its column data, parquet skips pages through the offset index,
+ /// `.row` prunes blocks. Avro is the exception — its reader loads the
whole file
+ /// and deserializes every record before applying the selection, so there a
+ /// narrow selection saves only what comes after decoding: Arrow column
+ /// materialization, and whatever the caller does per row.
+ pub(super) fn read_single_file_stream_local_ranges(
+ &self,
+ split: &DataSplit,
+ file_meta: DataFileMeta,
+ data_fields: Option<Vec<DataField>>,
+ dv: Option<Arc<DeletionVector>>,
+ local_ranges: Vec<RowRange>,
) -> crate::Result<ArrowRecordBatchStream> {
// Local-position selection is only sound against a predicate-free
reader: a
// row-filtering predicate drops arbitrary selected rows and desyncs
the
@@ -666,12 +696,10 @@ impl DataFileReader {
}
};
- // Interpret `local_positions` directly as file-local ranges (no
- // `to_local_row_ranges`, no `first_row_id`), then fold the DV in.
- // `merge_row_selection` intersects the selection with the file's
- // non-deleted ranges, so the reader emits exactly the selected,
non-deleted
- // rows in ascending physical order.
- let local_ranges =
coalesce_positions_to_local_ranges(&local_positions);
+ // Interpret the ranges directly as file-local (no
`to_local_row_ranges`, no
+ // `first_row_id`), then fold the DV in. `merge_row_selection`
intersects the
+ // selection with the file's non-deleted ranges, so the reader emits
exactly
+ // the selected, non-deleted rows in ascending physical order.
let row_selection =
merge_row_selection(file_meta.row_count, dv.as_deref(),
Some(&local_ranges));
diff --git a/crates/paimon/src/table/pk_vector_bucket_split.rs
b/crates/paimon/src/table/pk_vector_bucket_split.rs
index 0c50b675..c8282675 100644
--- a/crates/paimon/src/table/pk_vector_bucket_split.rs
+++ b/crates/paimon/src/table/pk_vector_bucket_split.rs
@@ -126,6 +126,39 @@ impl BucketVectorPayload {
.as_deref()
.expect("a decoded payload always carries source metadata")
}
+
+ /// Consume the payload into the pieces a planner needs, so its decoded
metadata
+ /// moves out of the payload rather than being cloned out of it.
+ ///
+ /// Two decoded fields are deliberately left behind. `row_count` is the
payload's
+ /// own row count, which the read path derives from the source metadata
instead.
+ /// `deletion_vectors_ranges` belongs to deletion-vector index files --
Java
+ /// builds a vector payload through the overload that leaves it null, and
a read
+ /// takes its deletion vectors from the bucket's data split -- so a value
here
+ /// describes something this payload is not, and is ignored rather than
applied.
+ #[cfg_attr(not(test), allow(dead_code))]
+ pub(crate) fn into_parts(self) -> BucketVectorPayloadParts {
+ BucketVectorPayloadParts {
+ index_type: self.index_type,
+ file_name: self.file_name,
+ file_size: self.file_size,
+ external_path: self.external_path,
+ global_index_meta: self.global_index_meta,
+ }
+ }
+}
+
+/// The owned pieces of a [`BucketVectorPayload`], produced by
+/// [`BucketVectorPayload::into_parts`].
+#[cfg_attr(not(test), allow(dead_code))]
+pub(crate) struct BucketVectorPayloadParts {
+ pub(crate) index_type: String,
+ pub(crate) file_name: String,
+ /// As decoded: Java writes a signed length, so a negative value is
possible on
+ /// the wire and is rejected where it is converted, not here.
+ pub(crate) file_size: i64,
+ pub(crate) external_path: Option<String>,
+ pub(crate) global_index_meta: GlobalIndexMeta,
}
impl BucketVectorSearchSplit {
@@ -144,6 +177,19 @@ impl BucketVectorSearchSplit {
&self.row_ranges_by_file
}
+ /// Consume the split into its three parts, so a planner can take
ownership of
+ /// the data split, the payloads and the row ranges without cloning them.
+ #[cfg_attr(not(test), allow(dead_code))]
+ pub(crate) fn into_parts(
+ self,
+ ) -> (
+ DataSplit,
+ Vec<BucketVectorPayload>,
+ IndexMap<String, Vec<RowRange>>,
+ ) {
+ (self.data_split, self.payload_files, self.row_ranges_by_file)
+ }
+
/// Parse a Java `BucketVectorSearchSplit#serialize` message.
///
/// Integers are big-endian and file names are Java modified UTF-8,
following
@@ -454,6 +500,49 @@ fn read_count(cur: &mut &[u8], element: &str) ->
crate::Result<usize> {
Ok(count)
}
+#[cfg(test)]
+impl BucketVectorSearchSplit {
+ /// Assemble a split directly, for tests that need shapes the decoder will
not
+ /// produce -- a nested split that wrongly carries row ranges, two splits
for one
+ /// bucket, a negative payload size. Production splits always come from
+ /// [`Self::deserialize`].
+ pub(crate) fn new_for_test(
+ data_split: DataSplit,
+ payload_files: Vec<BucketVectorPayload>,
+ row_ranges_by_file: IndexMap<String, Vec<RowRange>>,
+ ) -> Self {
+ Self {
+ data_split,
+ payload_files,
+ row_ranges_by_file,
+ }
+ }
+}
+
+#[cfg(test)]
+impl BucketVectorPayload {
+ #[allow(clippy::too_many_arguments)]
+ pub(crate) fn new_for_test(
+ index_type: &str,
+ file_name: &str,
+ file_size: i64,
+ row_count: i64,
+ deletion_vectors_ranges: Option<IndexMap<String, DeletionVectorMeta>>,
+ external_path: Option<String>,
+ global_index_meta: GlobalIndexMeta,
+ ) -> Self {
+ Self {
+ index_type: index_type.to_string(),
+ file_name: file_name.to_string(),
+ file_size,
+ row_count,
+ deletion_vectors_ranges,
+ external_path,
+ global_index_meta,
+ }
+ }
+}
+
#[cfg(test)]
mod tests {
use super::*;
diff --git a/crates/paimon/src/table/pk_vector_data_file_reader.rs
b/crates/paimon/src/table/pk_vector_data_file_reader.rs
index c3087beb..dc42f172 100644
--- a/crates/paimon/src/table/pk_vector_data_file_reader.rs
+++ b/crates/paimon/src/table/pk_vector_data_file_reader.rs
@@ -35,7 +35,7 @@ use futures::TryStreamExt;
use crate::spec::{DataField, DataType};
use crate::table::data_file_reader::DataFileReader;
-use crate::table::source::DataSplit;
+use crate::table::source::{DataSplit, RowRange};
use crate::vindex::pkvector::bucket::BucketActiveFile;
use crate::vindex::pkvector::exact::{drain_best_first, push_bounded,
validate_query, WorstFirst};
use crate::vindex::pkvector::metric::VectorSearchMetric;
@@ -95,8 +95,17 @@ impl DataFilePkVectorReaderFactory {
/// opened. Each surviving physical position (not NULL, not `is_excluded`)
is
/// scored against every query into that query's bounded heap; a NULL row
is
/// skipped but still advances the position so the position stays in
lockstep
- /// with `is_excluded`. The drained row count is checked against the file's
- /// `DataFileMeta.row_count` (both truncation and overrun fail loud).
+ /// with `is_excluded`. The read is checked against its selection:
emitting more
+ /// or fewer rows than were selected fails loud. A selected read can only
vouch
+ /// for the ranges it asked for, so unlike a full read it cannot notice a
file
+ /// truncated somewhere else.
+ ///
+ /// `allowed_rows`, when given, limits the read to those file-local
inclusive
+ /// ranges — normalized, as the plan carries them. An engine-supplied
bucket
+ /// split can restrict a large file to a handful of rows, and scoring is
not the
+ /// expensive part: reading the rest of the file to have `is_excluded`
reject it
+ /// afterwards is. `is_excluded` still applies on top, since it also folds
in a
+ /// residual predicate and the deletion vector.
pub(crate) async fn search_file(
&self,
file: &BucketActiveFile,
@@ -104,6 +113,7 @@ impl DataFilePkVectorReaderFactory {
metric: VectorSearchMetric,
exact_limit: usize,
is_excluded: &(dyn Fn(i64) -> bool + Sync),
+ allowed_rows: Option<&[RowRange]>,
) -> crate::Result<Vec<Vec<PkVectorSearchResult>>> {
if exact_limit == 0 {
return Err(data_invalid("vector search limit must be positive"));
@@ -129,21 +139,48 @@ impl DataFilePkVectorReaderFactory {
let row_count = file_meta.row_count;
let data_fields = self.reader.derive_data_fields(&file_meta).await?;
- let mut stream = self.reader.read_single_file_stream(
- &self.data_split,
- file_meta,
- data_fields,
- None,
- None,
- )?;
+ // An empty selection permits nothing, so there is nothing to open.
+ if allowed_rows.is_some_and(|ranges| ranges.is_empty()) {
+ return Ok(vec![Vec::new(); queries.len()]);
+ }
+ let mut stream = match allowed_rows {
+ Some(ranges) => self.reader.read_single_file_stream_local_ranges(
+ &self.data_split,
+ file_meta,
+ data_fields,
+ None,
+ ranges.to_vec(),
+ )?,
+ None => self.reader.read_single_file_stream(
+ &self.data_split,
+ file_meta,
+ data_fields,
+ None,
+ None,
+ )?,
+ };
+ // Rows arrive in ascending physical order and the read emits exactly
what
+ // was selected (no pushdown predicate, no deletion vector here), so
walking
+ // the selection in step with the rows gives each row its file-local
+ // position: the whole file when nothing restricted it.
+ let mut selected: Box<dyn Iterator<Item = i64> + Send> = match
allowed_rows {
+ Some(ranges) => {
+ let ranges: Vec<RowRange> = ranges.into();
+ Box::new(
+ ranges
+ .into_iter()
+ .flat_map(|range| range.from()..=range.to()),
+ )
+ }
+ None => Box::new(0..row_count.max(0)),
+ };
let mut heaps: Vec<BinaryHeap<WorstFirst>> = (0..queries.len())
.map(|_| BinaryHeap::with_capacity(exact_limit + 1))
.collect();
// One reused buffer per batch; a NULL row leaves it untouched (and is
not
- // scored). `position` is the monotonic physical row counter across
batches.
+ // scored) but still consumes its physical position.
let mut batch_vectors: Vec<Option<Vec<f32>>> = Vec::new();
- let mut position: i64 = 0;
while let Some(batch) = stream.try_next().await? {
batch_vectors.clear();
append_batch_vectors(
@@ -153,13 +190,11 @@ impl DataFilePkVectorReaderFactory {
&mut batch_vectors,
)?;
for entry in &batch_vectors {
- let pos = position;
- position += 1;
- if pos >= row_count {
+ let Some(pos) = selected.next() else {
return Err(data_invalid(
- "data file produced more rows than
DataFileMeta.row_count",
+ "data file produced more rows than the selection
allows",
));
- }
+ };
let Some(vector) = entry else {
continue; // NULL row: not scored, position already
advanced.
};
@@ -177,14 +212,10 @@ impl DataFilePkVectorReaderFactory {
}
}
- if position > row_count {
+ // The overrun side is caught above, when the selection runs dry
mid-batch.
+ if selected.next().is_some() {
return Err(data_invalid(
- "data file produced more rows than DataFileMeta.row_count",
- ));
- }
- if position < row_count {
- return Err(data_invalid(
- "data file ended before DataFileMeta.row_count",
+ "data file ended before the selection was exhausted",
));
}
@@ -431,6 +462,72 @@ mod integration_tests {
/// The streaming per-file search must produce candidates byte-identical to
/// the reference `exact_search` over an in-memory `ArrayReader` of the
same
/// data, including a NULL row and a residual/DV exclusion.
+ #[tokio::test]
+ async fn test_search_file_only_reads_the_rows_the_plan_allows() {
+ // Five rows; the plan allows physical positions 3-4 only. The nearest
rows to
+ // the origin are 0 and 1, so a result of 3 and 4 means the read never
saw
+ // them — the exact fallback does not need a data predicate to be
handed a
+ // narrow split, and reading the rest of the file to reject it
afterwards is
+ // what this avoids.
+ //
+ // A full read cannot pass either: positions come from the selection,
so five
+ // emitted rows against a two-row selection fails loudly.
+ let rows = vec![
+ Some(vec![0.0, 0.0]),
+ Some(vec![0.5, 0.0]),
+ Some(vec![5.0, 0.0]),
+ Some(vec![6.0, 0.0]),
+ Some(vec![7.0, 0.0]),
+ ];
+ let (factory, file_name) =
+ build_factory(&rows, rows.len() as i64,
"memory:/pkvdfr_plan_ranges").await;
+ let active = BucketActiveFile {
+ file_name: file_name.clone(),
+ row_count: rows.len() as i64,
+ };
+ let query = [0.0f32, 0.0];
+
+ let results = factory
+ .search_file(
+ &active,
+ &[&query],
+ VectorSearchMetric::L2,
+ 5,
+ &|_| false,
+ Some(&[RowRange::new(3, 4)]),
+ )
+ .await
+ .unwrap();
+ let positions: Vec<i64> = results[0].iter().map(|hit|
hit.row_position).collect();
+ assert_eq!(positions, vec![3, 4]);
+ }
+
+ #[tokio::test]
+ async fn test_search_file_reads_nothing_for_an_empty_selection() {
+ let rows = vec![Some(vec![0.0, 0.0]), Some(vec![1.0, 0.0])];
+ let (factory, file_name) =
+ build_factory(&rows, rows.len() as i64,
"memory:/pkvdfr_empty_selection").await;
+ let active = BucketActiveFile {
+ file_name,
+ row_count: rows.len() as i64,
+ };
+ let query = [0.0f32, 0.0];
+
+ let results = factory
+ .search_file(
+ &active,
+ &[&query],
+ VectorSearchMetric::L2,
+ 5,
+ &|_| false,
+ Some(&[]),
+ )
+ .await
+ .unwrap();
+ assert_eq!(results.len(), 1, "one query in, one result list out");
+ assert!(results[0].is_empty());
+ }
+
#[tokio::test]
async fn search_file_matches_exact_search_reference() {
use crate::vindex::pkvector::exact::exact_search;
@@ -454,7 +551,14 @@ mod integration_tests {
let query = [0.0f32, 0.0];
let streamed = factory
- .search_file(&active, &[&query], VectorSearchMetric::L2, 2,
&is_excluded)
+ .search_file(
+ &active,
+ &[&query],
+ VectorSearchMetric::L2,
+ 2,
+ &is_excluded,
+ None,
+ )
.await
.unwrap();
@@ -498,7 +602,14 @@ mod integration_tests {
let query = [0.0f32, 0.0];
let streamed = factory
- .search_file(&active, &[&query], VectorSearchMetric::L2, 2, &|_|
false)
+ .search_file(
+ &active,
+ &[&query],
+ VectorSearchMetric::L2,
+ 2,
+ &|_| false,
+ None,
+ )
.await
.unwrap();
@@ -549,6 +660,7 @@ mod integration_tests {
VectorSearchMetric::L2,
2,
&is_excluded,
+ None,
)
.await
.unwrap();
@@ -556,11 +668,25 @@ mod integration_tests {
// Each query searched alone must equal its slot in the batch.
let only_q0 = factory
- .search_file(&active, &[&q0], VectorSearchMetric::L2, 2,
&is_excluded)
+ .search_file(
+ &active,
+ &[&q0],
+ VectorSearchMetric::L2,
+ 2,
+ &is_excluded,
+ None,
+ )
.await
.unwrap();
let only_q1 = factory
- .search_file(&active, &[&q1], VectorSearchMetric::L2, 2,
&is_excluded)
+ .search_file(
+ &active,
+ &[&q1],
+ VectorSearchMetric::L2,
+ 2,
+ &is_excluded,
+ None,
+ )
.await
.unwrap();
assert_eq!(batch[0], only_q0[0]);
@@ -588,7 +714,14 @@ mod integration_tests {
};
let query = [0.0f32, 0.0];
let err = factory
- .search_file(&active, &[&query], VectorSearchMetric::L2, 2, &|_|
false)
+ .search_file(
+ &active,
+ &[&query],
+ VectorSearchMetric::L2,
+ 2,
+ &|_| false,
+ None,
+ )
.await
.expect_err("row-count truncation must fail loud");
assert!(err.to_string().contains("ended before"), "got: {err}");
@@ -609,7 +742,14 @@ mod integration_tests {
// Wrong dimension.
let bad_dim = [1.0f32];
let err = factory
- .search_file(&present, &[&bad_dim], VectorSearchMetric::L2, 2,
&|_| false)
+ .search_file(
+ &present,
+ &[&bad_dim],
+ VectorSearchMetric::L2,
+ 2,
+ &|_| false,
+ None,
+ )
.await
.expect_err("dimension mismatch must fail loud");
assert!(err.to_string().contains("dimension"), "got: {err}");
@@ -617,9 +757,14 @@ mod integration_tests {
// Non-finite element.
let bad_finite = [f32::NAN, 0.0];
let err = factory
- .search_file(&present, &[&bad_finite], VectorSearchMetric::L2, 2,
&|_| {
- false
- })
+ .search_file(
+ &present,
+ &[&bad_finite],
+ VectorSearchMetric::L2,
+ 2,
+ &|_| false,
+ None,
+ )
.await
.expect_err("non-finite query must fail loud");
assert!(err.to_string().contains("finite"), "got: {err}");
@@ -631,7 +776,14 @@ mod integration_tests {
};
let query = [0.0f32, 0.0];
let err = factory
- .search_file(&missing, &[&query], VectorSearchMetric::L2, 2, &|_|
false)
+ .search_file(
+ &missing,
+ &[&query],
+ VectorSearchMetric::L2,
+ 2,
+ &|_| false,
+ None,
+ )
.await
.expect_err("absent file must be rejected");
assert!(matches!(err, crate::Error::DataInvalid { .. }));
diff --git a/crates/paimon/src/table/pk_vector_scan.rs
b/crates/paimon/src/table/pk_vector_scan.rs
index 8521aad6..21d70d1d 100644
--- a/crates/paimon/src/table/pk_vector_scan.rs
+++ b/crates/paimon/src/table/pk_vector_scan.rs
@@ -20,15 +20,22 @@
//! search split per bucket. Mirror of Java `PrimaryKeyVectorScan` and
//! `PrimaryKeyIndexSourcePolicy`.
-use std::collections::{BTreeMap, HashSet};
+use std::collections::{BTreeMap, HashMap, HashSet};
+
+use indexmap::IndexMap;
+
+use roaring::RoaringTreemap;
use crate::spec::{
should_read_pk_index_source, BinaryRow, DataFileMeta, FileKind,
IndexManifest, Predicate,
PrimaryKeyIndexSourceFile, PrimaryKeyIndexSourceMeta,
};
+use crate::table::bucket_filter::split_partition_and_data_predicates;
use crate::table::index_file_path::IndexFileLocation;
+use crate::table::partition_filter::PartitionFilter;
+use crate::table::pk_vector_bucket_split::BucketVectorSearchSplit;
use crate::table::pk_vector_orchestrator::PkVectorSearchSplit;
-use crate::table::source::{DataSplit, DataSplitBuilder, DeletionFile};
+use crate::table::source::{merge_row_ranges, DataSplit, DataSplitBuilder,
DeletionFile, RowRange};
use crate::table::Table;
use crate::vindex::pkvector::bucket::{BucketActiveFile, BucketAnnSegment};
@@ -42,6 +49,41 @@ struct UnresolvedAnnSegment {
index_meta: Vec<u8>,
}
+/// A bucket's identity across planning inputs: the partition's serialized
bytes
+/// (`BinaryRow` is not hashable) paired with the bucket number.
+type BucketKey = (Vec<u8>, i32);
+
+/// Expand inclusive row ranges into the positions they allow. Only the search
+/// kernel's membership tests need this; a read is limited by the ranges
themselves.
+pub(super) fn positions_in_ranges(ranges: &[RowRange]) ->
crate::Result<RoaringTreemap> {
+ let mut positions = RoaringTreemap::new();
+ for range in ranges {
+ let from = u64::try_from(range.from())
+ .map_err(|_| data_invalid("row range bound must not be
negative"))?;
+ let to = u64::try_from(range.to())
+ .map_err(|_| data_invalid("row range bound must not be
negative"))?;
+ positions.insert_range(from..=to);
+ }
+ Ok(positions)
+}
+
+/// The whole of a file, for a file the message left unrestricted.
+///
+/// A zero-row file gets no range rather than an empty one: `RowRange` is
inclusive,
+/// so it cannot express "nothing". An unknown count
(`DataFileMeta::ROW_COUNT_UNKNOWN`,
+/// or anything else negative) is rejected rather than read as "nothing",
which would
+/// silently drop the file from the search. Decoding only checks the count of
a file
+/// the message lists ranges for, so this is where an omitted one is checked.
+fn whole_file_range(row_count: i64) -> crate::Result<Vec<RowRange>> {
+ match row_count {
+ 0 => Ok(Vec::new()),
+ count if count > 0 => Ok(vec![RowRange::new(0, count - 1)]),
+ count => Err(data_invalid(format!(
+ "data file row count must be known and non-negative, got {count}"
+ ))),
+ }
+}
+
fn data_invalid(message: impl Into<String>) -> crate::Error {
crate::Error::DataInvalid {
message: message.into(),
@@ -206,6 +248,17 @@ pub(crate) struct PkVectorScanPlan {
// at all (never written), which also yields empty `splits`.
pub snapshot_id: i64,
pub splits: Vec<PkVectorSearchSplit>,
+ // Per-split allow-list of physical rows, indexed parallel to `splits`:
only the
+ // rows listed for a data file may produce candidates from it. Ranges
rather than
+ // materialized positions, because this is what a read is limited to —
expanding
+ // a whole-file range of a large file into positions costs memory no
reader needs.
+ // Each list is normalized: sorted, non-overlapping, inclusive, file-local.
+ // Populated when the plan was built from engine-supplied bucket splits,
which
+ // carry row ranges the engine's own planner already resolved. `None` for
a plan
+ // read from this table's index manifest, which places no positional
restriction
+ // of its own -- distinct from `Some` of an empty allow-list, which permits
+ // nothing.
+ pub physical_row_ranges_by_split: Option<Vec<HashMap<String,
Vec<RowRange>>>>,
}
pub(crate) struct PkVectorScan<'a> {
@@ -271,6 +324,7 @@ impl<'a> PkVectorScan<'a> {
return Ok(PkVectorScanPlan {
snapshot_id: 0,
splits: Vec::new(),
+ physical_row_ranges_by_split: None,
});
};
let snapshot = snapshot_manager.get_snapshot(snapshot_id).await?;
@@ -344,8 +398,223 @@ impl<'a> PkVectorScan<'a> {
Ok(PkVectorScanPlan {
snapshot_id,
splits,
+ physical_row_ranges_by_split: None,
})
}
+
+ /// Build a plan from bucket splits an engine planned elsewhere, instead
of from
+ /// this table's index manifest.
+ ///
+ /// The splits are the planning input and are taken as authoritative: their
+ /// payload files, their per-file row ranges, and the snapshot they pin
are used
+ /// as given, and no index manifest is read. Only the partition conjuncts
of this
+ /// scan's filter are re-applied, because a caller may narrow the query
further
+ /// than the planner that produced the splits.
+ ///
+ /// Mirrors what Java's `PrimaryKeyVectorRead` does with a
+ /// `BucketVectorSearchSplit`: search the payloads the split names, over
the rows
+ /// the split allows.
+ // Entry point for engine-supplied splits; no in-tree caller reads a plan
from
+ // them yet, and the tests drive `plan_from_bucket_splits` directly.
+ #[allow(dead_code)]
+ pub(crate) fn plan_for_bucket_vector_splits(
+ &self,
+ splits: Vec<BucketVectorSearchSplit>,
+ ) -> crate::Result<PkVectorScanPlan> {
+ // Partition conjuncts only. Data conjuncts stay a per-row residual
applied
+ // during the search: pruning a whole bucket on them would drop rows
that
+ // still match.
+ let partition_filter = self.filter.as_ref().and_then(|filter| {
+ let (partition_predicate, _data_predicates) =
split_partition_and_data_predicates(
+ filter.clone(),
+ self.table.schema().fields(),
+ self.table.schema().partition_keys(),
+ );
+ partition_predicate.map(|predicate| {
+ PartitionFilter::from_predicate(predicate,
&self.table.schema().partition_fields())
+ })
+ });
+ plan_from_bucket_splits(
+ &self.index_type,
+ self.vector_field_id,
+ partition_filter.as_ref(),
+ self.table.location().trim_end_matches('/'),
+ self.table
+ .schema()
+ .core_options()
+ .index_file_in_data_file_dir(),
+ splits,
+ )
+ }
+}
+
+/// The `Table`-independent core of
[`PkVectorScan::plan_for_bucket_vector_splits`],
+/// so planning from engine-supplied splits is testable the same way planning
from a
+/// manifest is.
+#[cfg_attr(not(test), allow(dead_code))]
+fn plan_from_bucket_splits(
+ index_type: &str,
+ vector_field_id: i32,
+ partition_filter: Option<&PartitionFilter>,
+ table_path: &str,
+ index_file_in_data_file_dir: bool,
+ splits: Vec<BucketVectorSearchSplit>,
+) -> crate::Result<PkVectorScanPlan> {
+ // A plan's snapshot id stays authoritative even when nothing is
searchable, and
+ // empty input pins no snapshot to report. Reject rather than invent one.
+ if splits.is_empty() {
+ return Err(data_invalid(
+ "bucket-split planning requires at least one bucket split",
+ ));
+ }
+
+ let mut snapshot_id: Option<i64> = None;
+ let mut seen_buckets: HashSet<BucketKey> = HashSet::new();
+ let mut data_splits: Vec<DataSplit> = Vec::with_capacity(splits.len());
+ let mut index_entries: Vec<(BinaryRow, i32, UnresolvedAnnSegment)> =
Vec::new();
+ let mut listed_ranges: HashMap<BucketKey, IndexMap<String, Vec<RowRange>>>
= HashMap::new();
+
+ for split in splits {
+ let (data_split, payload_files, row_ranges_by_file) =
split.into_parts();
+
+ // Row ranges belong to the bucket form, one list per data file. A
nested
+ // split carrying its own would be a second authority over which
physical
+ // rows are readable, free to disagree with the first. Java's planner
+ // builds the nested split without them.
+ if data_split.row_ranges().is_some() {
+ return Err(data_invalid(
+ "a bucket split's nested data split must not carry row ranges",
+ ));
+ }
+
+ // One snapshot across every split: candidates found under different
+ // snapshots cannot be merged into a single Top-K. Checked before
pruning,
+ // so a mismatch is reported even when the offending split would have
been
+ // pruned away and the inconsistency left no trace.
+ match snapshot_id {
+ None => snapshot_id = Some(data_split.snapshot_id()),
+ Some(pinned) if pinned != data_split.snapshot_id() => {
+ return Err(data_invalid(format!(
+ "bucket splits pin different snapshots: {} and {}",
+ pinned,
+ data_split.snapshot_id()
+ )));
+ }
+ Some(_) => {}
+ }
+
+ // Java emits exactly one split per (partition, bucket). Buffers
decoded
+ // independently cannot enforce that between them, and two splits for
one
+ // bucket would search its rows twice.
+ let key: BucketKey = (
+ data_split.partition().to_serialized_bytes(),
+ data_split.bucket(),
+ );
+ if !seen_buckets.insert(key.clone()) {
+ return Err(data_invalid(format!(
+ "bucket splits repeat bucket {} of one partition",
+ data_split.bucket()
+ )));
+ }
+
+ if let Some(filter) = partition_filter {
+ if !filter.matches_entry(&key.0)? {
+ continue;
+ }
+ }
+
+ for payload in payload_files {
+ let parts = payload.into_parts();
+ // The same three filters the manifest route applies: this column's
+ // index type, this column's field id, and a payload that carries
the
+ // source metadata a search needs to map ordinals back to rows.
+ if parts.index_type != index_type
+ || parts.global_index_meta.index_field_id != vector_field_id
+ || parts.global_index_meta.source_meta.is_none()
+ {
+ continue;
+ }
+ // Java writes the size as a signed long, so the wire allows a
+ // negative value the segment addressing cannot represent.
+ let file_size = u64::try_from(parts.file_size)
+ .map_err(|_| data_invalid("index file size must not be
negative"))?;
+ let source_meta =
+
PrimaryKeyIndexSourceMeta::from_global_index_meta(&parts.global_index_meta)
+ .map_err(|_| {
+ data_invalid(format!("index file {} is not active",
parts.file_name))
+ })?;
+ let index_meta = parts
+ .global_index_meta
+ .index_meta
+ .clone()
+ .unwrap_or_default();
+ index_entries.push((
+ data_split.partition().clone(),
+ data_split.bucket(),
+ UnresolvedAnnSegment {
+ source_meta,
+ file_name: parts.file_name,
+ external_path: parts.external_path,
+ file_size,
+ index_meta,
+ },
+ ));
+ }
+
+ listed_ranges.insert(key, row_ranges_by_file);
+ data_splits.push(data_split);
+ }
+
+ // Non-empty input always pins one: the first split sets it and a mismatch
+ // returns early.
+ let snapshot_id = snapshot_id.expect("non-empty bucket-split input pins a
snapshot");
+
+ let splits = plan_from_inputs(
+ snapshot_id,
+ data_splits,
+ index_entries,
+ table_path,
+ index_file_in_data_file_dir,
+ )?;
+
+ // Normalize the row ranges against the planned splits, which are grouped
by
+ // bucket and so may be ordered differently from the input.
+ //
+ // A file the message lists is restricted to the positions it lists. A
file it
+ // omits is unrestricted: Java records ranges only for the files its own
+ // pre-filter narrowed, and leaves the rest out. The search kernel reads a
+ // missing entry as "no rows allowed", the opposite meaning, so the
omission
+ // has to be turned into an explicit full-file range here rather than
passed
+ // through.
+ let physical_row_ranges_by_split = splits
+ .iter()
+ .map(|split| {
+ let listed = listed_ranges.get(&(
+ split.data_split.partition().to_serialized_bytes(),
+ split.data_split.bucket(),
+ ));
+ split
+ .data_split
+ .data_files()
+ .iter()
+ .map(|file| {
+ let allowed = match listed.and_then(|ranges|
ranges.get(&file.file_name)) {
+ // Decoding checks each range's bounds but not their
order or
+ // whether they overlap, and a read needs them
normalized.
+ Some(ranges) => merge_row_ranges(ranges.clone()),
+ None => whole_file_range(file.row_count)?,
+ };
+ Ok((file.file_name.clone(), allowed))
+ })
+ .collect::<crate::Result<HashMap<String, Vec<RowRange>>>>()
+ })
+ .collect::<crate::Result<Vec<_>>>()?;
+
+ Ok(PkVectorScanPlan {
+ snapshot_id,
+ splits,
+ physical_row_ranges_by_split: Some(physical_row_ranges_by_split),
+ })
}
/// Pure planning core, drivable without a live snapshot: group ANN payloads
and
@@ -425,6 +694,8 @@ mod tests {
use super::*;
use crate::spec::stats::BinaryTableStats;
use crate::spec::{BinaryRow, DataFileMeta, GlobalIndexMeta};
+ use crate::spec::{DataField, DeletionVectorMeta};
+ use crate::table::pk_vector_bucket_split::BucketVectorPayload;
use crate::table::source::{DataSplitBuilder, DeletionFile};
fn dfm(name: &str, rows: i64, level: i32, file_source: Option<i32>) ->
DataFileMeta {
@@ -992,4 +1263,404 @@ mod tests {
);
}
}
+
+ // ---- planning from engine-supplied bucket splits ----
+
+ const BUCKET_SPLIT_GOLDEN: &[u8] =
include_bytes!("goldens/bucket_vector_search_split_v1.bin");
+
+ fn int_partition(value: i32) -> BinaryRow {
+ let mut builder = crate::spec::BinaryRowBuilder::new(1);
+ builder.write_int(0, value);
+ BinaryRow::from_serialized_bytes(&builder.build_serialized()).unwrap()
+ }
+
+ /// A bucket split as an engine would hand one over. Its data files are
COMPACT
+ /// above level 0, so they are exact-fallback eligible, and the caller's
payload
+ /// metadata is expected to name exactly that level's source set.
+ fn engine_split(
+ snapshot: i64,
+ bucket: i32,
+ partition: BinaryRow,
+ files: Vec<DataFileMeta>,
+ payloads: Vec<BucketVectorPayload>,
+ ranges: &[(&str, &[(i64, i64)])],
+ ) -> BucketVectorSearchSplit {
+ let data_split = DataSplitBuilder::new()
+ .with_snapshot(snapshot)
+ .with_partition(partition)
+ .with_bucket(bucket)
+ .with_bucket_path(format!("bucket-{bucket}"))
+ .with_total_buckets(1)
+ .with_data_files(files)
+ .build()
+ .unwrap();
+ BucketVectorSearchSplit::new_for_test(
+ data_split,
+ payloads,
+ ranges
+ .iter()
+ .map(|(name, bounds)| {
+ (
+ (*name).to_string(),
+ bounds
+ .iter()
+ .map(|(from, to)| RowRange::new(*from, *to))
+ .collect(),
+ )
+ })
+ .collect(),
+ )
+ }
+
+ fn engine_payload(meta: GlobalIndexMeta) -> BucketVectorPayload {
+ BucketVectorPayload::new_for_test("ivf-pq", "seg0", 1, 4, None, None,
meta)
+ }
+
+ /// One bucket holding `d0`, with a payload whose source set matches it.
+ fn one_file_split(
+ snapshot: i64,
+ bucket: i32,
+ ranges: &[(&str, &[(i64, i64)])],
+ ) -> BucketVectorSearchSplit {
+ engine_split(
+ snapshot,
+ bucket,
+ BinaryRow::new(0),
+ vec![dfm("d0", 4, 5, Some(1))],
+ vec![engine_payload(gim(2, 5, &[("d0", 4)]))],
+ ranges,
+ )
+ }
+
+ /// The positions a file's normalized ranges allow, for assertions that
read
+ /// better as a row list than as ranges.
+ fn allowed(map: &HashMap<String, Vec<RowRange>>, file: &str) -> Vec<u64> {
+ map.get(file)
+ .map(|ranges| {
+ positions_in_ranges(ranges)
+ .expect("planned ranges are in range")
+ .iter()
+ .collect()
+ })
+ .unwrap_or_default()
+ }
+
+ #[test]
+ fn plans_the_java_golden_bucket_split() {
+ let split =
BucketVectorSearchSplit::deserialize(BUCKET_SPLIT_GOLDEN).unwrap();
+ let plan = plan_from_bucket_splits("ivf-pq", 7, None, "/tbl", false,
vec![split]).unwrap();
+
+ // The snapshot the split pins, not one re-resolved from the table.
+ assert_eq!(plan.snapshot_id, 11);
+ assert_eq!(plan.splits.len(), 1);
+ let planned = &plan.splits[0];
+
+ // The payload's own external path wins over both directory layouts.
+ assert_eq!(planned.ann_segments.len(), 1);
+ assert_eq!(planned.ann_segments[0].path,
"s3://vector-bucket/ann-0.idx");
+ assert_eq!(planned.ann_segments[0].file_size, 5_000_000_000);
+ assert_eq!(planned.ann_segments[0].source_meta.data_level(), 1);
+
+ // `data-1.orc` is COMPACT above level 0, so exact fallback may read
it.
+ assert_eq!(planned.active_files.len(), 1);
+ assert_eq!(planned.active_files[0].file_name, "data-1.orc");
+
+ // The message allows rows 0-1 and 4-5 of a six-row file.
+ let ranges = plan
+ .physical_row_ranges_by_split
+ .expect("a split-driven plan restricts positions");
+ assert_eq!(allowed(&ranges[0], "data-1.orc"), vec![0, 1, 4, 5]);
+ }
+
+ #[test]
+ fn rejects_an_unknown_row_count_on_an_unlisted_file() {
+ // A file the message lists no ranges for is read as "the whole file",
which
+ // needs a real row count. `ROW_COUNT_UNKNOWN` is -1, and reading that
as "no
+ // rows" would drop the file from the search without a word; the
decoder only
+ // checks the count of files it does carry ranges for.
+ let error = whole_file_range(DataFileMeta::ROW_COUNT_UNKNOWN)
+ .map(|_| ())
+ .expect_err("an unknown row count cannot stand in for the whole
file");
+ assert!(error.to_string().contains("must be known"), "{error}");
+ assert!(whole_file_range(0).unwrap().is_empty());
+ assert_eq!(whole_file_range(3).unwrap(), vec![RowRange::new(0, 2)]);
+ }
+
+ #[test]
+ fn rejects_empty_bucket_split_input() {
+ let error = plan_from_bucket_splits("ivf-pq", 2, None, "/tbl", false,
Vec::new())
+ .map(|_| ())
+ .expect_err("empty input pins no snapshot to report");
+ assert!(
+ error.to_string().contains("at least one bucket split"),
+ "{error}"
+ );
+ }
+
+ #[test]
+ fn rejects_bucket_splits_pinning_different_snapshots() {
+ let error = plan_from_bucket_splits(
+ "ivf-pq",
+ 2,
+ None,
+ "/tbl",
+ false,
+ vec![one_file_split(11, 0, &[]), one_file_split(12, 1, &[])],
+ )
+ .map(|_| ())
+ .expect_err("candidates from two snapshots cannot merge into one
Top-K");
+ assert!(
+ error.to_string().contains("pin different snapshots"),
+ "{error}"
+ );
+ }
+
+ #[test]
+ fn rejects_two_splits_for_one_bucket() {
+ let error = plan_from_bucket_splits(
+ "ivf-pq",
+ 2,
+ None,
+ "/tbl",
+ false,
+ vec![one_file_split(11, 0, &[]), one_file_split(11, 0, &[])],
+ )
+ .map(|_| ())
+ .expect_err("one bucket twice would search its rows twice");
+ assert!(error.to_string().contains("repeat bucket 0"), "{error}");
+ }
+
+ #[test]
+ fn rejects_nested_data_split_carrying_row_ranges() {
+ let data_split = DataSplitBuilder::new()
+ .with_snapshot(11)
+ .with_partition(BinaryRow::new(0))
+ .with_bucket(0)
+ .with_bucket_path("bucket-0".to_string())
+ .with_total_buckets(1)
+ .with_data_files(vec![dfm("d0", 4, 5, Some(1))])
+ .with_row_ranges(vec![RowRange::new(0, 1)])
+ .build()
+ .unwrap();
+ let split = BucketVectorSearchSplit::new_for_test(
+ data_split,
+ vec![engine_payload(gim(2, 5, &[("d0", 4)]))],
+ IndexMap::new(),
+ );
+ let error = plan_from_bucket_splits("ivf-pq", 2, None, "/tbl", false,
vec![split])
+ .map(|_| ())
+ .expect_err("two row-range authorities may disagree");
+ assert!(
+ error.to_string().contains("must not carry row ranges"),
+ "{error}"
+ );
+ }
+
+ #[test]
+ fn unlisted_file_is_unrestricted_and_an_empty_list_excludes_one() {
+ // Java records ranges only for the files its own pre-filter narrowed,
so an
+ // omitted file means "all rows". An explicitly empty list means "no
rows",
+ // and the two must not collapse into each other.
+ let split = engine_split(
+ 11,
+ 0,
+ BinaryRow::new(0),
+ vec![dfm("d0", 4, 5, Some(1)), dfm("d1", 3, 5, Some(1))],
+ vec![engine_payload(gim(2, 5, &[("d0", 4), ("d1", 3)]))],
+ &[("d0", &[])],
+ );
+ let plan = plan_from_bucket_splits("ivf-pq", 2, None, "/tbl", false,
vec![split]).unwrap();
+ let ranges = plan
+ .physical_row_ranges_by_split
+ .expect("split-driven plan");
+
+ assert!(allowed(&ranges[0], "d0").is_empty());
+ assert_eq!(allowed(&ranges[0], "d1"), vec![0, 1, 2]);
+ }
+
+ #[test]
+ fn rejects_negative_payload_file_size() {
+ let split = engine_split(
+ 11,
+ 0,
+ BinaryRow::new(0),
+ vec![dfm("d0", 4, 5, Some(1))],
+ vec![BucketVectorPayload::new_for_test(
+ "ivf-pq",
+ "seg0",
+ -1,
+ 4,
+ None,
+ None,
+ gim(2, 5, &[("d0", 4)]),
+ )],
+ &[],
+ );
+ let error = plan_from_bucket_splits("ivf-pq", 2, None, "/tbl", false,
vec![split])
+ .map(|_| ())
+ .expect_err("a signed wire size can be negative, segment
addressing cannot");
+ assert!(
+ error.to_string().contains("must not be negative"),
+ "{error}"
+ );
+ }
+
+ #[test]
+ fn ignores_payload_deletion_vector_ranges() {
+ // The field belongs to deletion-vector index files. Java builds a
vector
+ // payload through the overload that leaves it null, and a read takes
its
+ // deletion vectors from the bucket's data split, so a value here
describes
+ // something this payload is not.
+ let mut dv = IndexMap::new();
+ dv.insert(
+ "d0".to_string(),
+ DeletionVectorMeta {
+ offset: 0,
+ length: 8,
+ cardinality: Some(1),
+ },
+ );
+ let split = engine_split(
+ 11,
+ 0,
+ BinaryRow::new(0),
+ vec![dfm("d0", 4, 5, Some(1))],
+ vec![BucketVectorPayload::new_for_test(
+ "ivf-pq",
+ "seg0",
+ 1,
+ 4,
+ Some(dv),
+ None,
+ gim(2, 5, &[("d0", 4)]),
+ )],
+ &[],
+ );
+ let plan = plan_from_bucket_splits("ivf-pq", 2, None, "/tbl", false,
vec![split]).unwrap();
+ assert_eq!(plan.splits.len(), 1);
+ assert_eq!(plan.splits[0].ann_segments.len(), 1);
+ let ranges = plan
+ .physical_row_ranges_by_split
+ .expect("split-driven plan");
+ // Unaffected: the whole file stays readable.
+ assert_eq!(allowed(&ranges[0], "d0"), vec![0, 1, 2, 3]);
+ }
+
+ #[test]
+ fn skips_payloads_for_another_column_or_index_type() {
+ let split = engine_split(
+ 11,
+ 0,
+ BinaryRow::new(0),
+ vec![dfm("d0", 4, 5, Some(1))],
+ vec![
+ // Another column's vector index.
+ engine_payload(gim(99, 5, &[("d0", 4)])),
+ // This column, but another index type.
+ BucketVectorPayload::new_for_test(
+ "flat",
+ "seg1",
+ 1,
+ 4,
+ None,
+ None,
+ gim(2, 5, &[("d0", 4)]),
+ ),
+ ],
+ &[],
+ );
+ let plan = plan_from_bucket_splits("ivf-pq", 2, None, "/tbl", false,
vec![split]).unwrap();
+ assert_eq!(plan.splits.len(), 1);
+ assert!(plan.splits[0].ann_segments.is_empty());
+ // Still exact-fallback eligible: no ANN segment covers the file.
+ assert_eq!(plan.splits[0].active_files.len(), 1);
+ }
+
+ fn partition_filter_on_dt(keep: i32) -> PartitionFilter {
+ let fields = vec![DataField::new(
+ 0,
+ "dt".to_string(),
+ crate::spec::DataType::Int(crate::spec::IntType::new()),
+ )];
+ let builder = crate::spec::PredicateBuilder::new(&fields);
+ let predicate = builder.equal("dt",
crate::spec::Datum::Int(keep)).unwrap();
+ PartitionFilter::from_predicate(predicate, &fields)
+ }
+
+ #[test]
+ fn snapshot_mismatch_is_rejected_before_partition_pruning() {
+ // Both splits are pruned by this filter. The mismatch must still be
reported:
+ // pruning first would hide an inconsistent input behind an empty plan.
+ let filter = partition_filter_on_dt(3);
+ let error = plan_from_bucket_splits(
+ "ivf-pq",
+ 2,
+ Some(&filter),
+ "/tbl",
+ false,
+ vec![
+ engine_split(
+ 11,
+ 0,
+ int_partition(1),
+ vec![dfm("d0", 4, 5, Some(1))],
+ vec![engine_payload(gim(2, 5, &[("d0", 4)]))],
+ &[],
+ ),
+ engine_split(
+ 12,
+ 1,
+ int_partition(2),
+ vec![dfm("d0", 4, 5, Some(1))],
+ vec![engine_payload(gim(2, 5, &[("d0", 4)]))],
+ &[],
+ ),
+ ],
+ )
+ .map(|_| ())
+ .expect_err("a snapshot mismatch outranks pruning");
+ assert!(
+ error.to_string().contains("pin different snapshots"),
+ "{error}"
+ );
+ }
+
+ #[test]
+ fn pruning_every_split_keeps_the_pinned_snapshot() {
+ let filter = partition_filter_on_dt(3);
+ let plan = plan_from_bucket_splits(
+ "ivf-pq",
+ 2,
+ Some(&filter),
+ "/tbl",
+ false,
+ vec![engine_split(
+ 11,
+ 0,
+ int_partition(1),
+ vec![dfm("d0", 4, 5, Some(1))],
+ vec![engine_payload(gim(2, 5, &[("d0", 4)]))],
+ &[],
+ )],
+ )
+ .unwrap();
+ assert!(plan.splits.is_empty());
+ // Still authoritative with nothing left to search.
+ assert_eq!(plan.snapshot_id, 11);
+ assert_eq!(
+ plan.physical_row_ranges_by_split.as_deref(),
+ Some([].as_slice())
+ );
+ }
+
+ #[test]
+ fn resolves_a_payload_without_an_external_path_into_the_bucket_directory()
{
+ let split = one_file_split(11, 0, &[]);
+ let plan = plan_from_bucket_splits("ivf-pq", 2, None, "/tbl", true,
vec![split]).unwrap();
+ assert_eq!(plan.splits[0].ann_segments[0].path, "bucket-0/seg0");
+
+ let split = one_file_split(11, 0, &[]);
+ let plan = plan_from_bucket_splits("ivf-pq", 2, None, "/tbl", false,
vec![split]).unwrap();
+ assert_eq!(plan.splits[0].ann_segments[0].path, "/tbl/index/seg0");
+ }
}
diff --git a/crates/paimon/src/table/vector_search_builder.rs
b/crates/paimon/src/table/vector_search_builder.rs
index 6bf11330..447d6995 100644
--- a/crates/paimon/src/table/vector_search_builder.rs
+++ b/crates/paimon/src/table/vector_search_builder.rs
@@ -44,7 +44,7 @@ use crate::table::pk_vector_orchestrator::{
use crate::table::pk_vector_position_read::{
PkVectorPositionRead, PKEY_VECTOR_POSITION_COLUMN, SEARCH_SCORE_COLUMN,
};
-use crate::table::pk_vector_scan::{PkVectorScan, PkVectorScanPlan};
+use crate::table::pk_vector_scan::{positions_in_ranges, PkVectorScan,
PkVectorScanPlan};
use crate::table::read_builder::resolve_projected_fields;
use crate::table::row_id_predicate::intersect_sorted_ranges;
use crate::table::source::DataSplit;
@@ -762,7 +762,26 @@ pub(crate) fn ensure_no_reserved_read_columns(fields:
&[DataField]) -> crate::Re
/// vector, so it is computed once and the SAME slice is shared across all
queries.
/// Rerank stays per-query (each query reranks its own indexed list).
#[allow(clippy::too_many_arguments)]
-async fn plan_and_search_pk_candidates_batch(
+/// Query-level parameters for a primary-key vector search: everything
resolvable
+/// from the table schema, the options and the queries alone, independent of
which
+/// splits planning yields. Resolved before planning so a malformed query or
option
+/// fails loud even when the plan turns out empty.
+struct PkVectorSearchParams {
+ metric: VectorSearchMetric,
+ /// Fan-out limit for bucket orchestration plus ANN and exact-file leaves
(Java
+ /// `GLOBAL_INDEX_THREAD_NUM`); `1` reproduces strictly sequential
execution.
+ concurrency: usize,
+ index_type: String,
+ field_id: i32,
+ vector_field: DataField,
+ skip_exact_fallback: bool,
+ refine_factor: usize,
+ indexed_limit: usize,
+}
+
+/// Resolve the query-level parameters and reject a query the search cannot
answer
+/// correctly, before any planning or read happens.
+fn resolve_pk_vector_search_params(
table: &Table,
query_options: &HashMap<String, String>,
filter: Option<&Predicate>,
@@ -770,11 +789,7 @@ async fn plan_and_search_pk_candidates_batch(
pk_col: &str,
queries: &[&[f32]],
limit: usize,
-) -> crate::Result<(
- Vec<Vec<PkVectorCandidate>>,
- PkVectorScanPlan,
- VectorSearchMetric,
-)> {
+) -> crate::Result<PkVectorSearchParams> {
// Residual pre-filter guard, mirroring Java `PrimaryKeyVectorScan`. A DATA
// predicate set via `with_filter` is applied post-recall by re-reading
each
// candidate file's physical rows (see below). That physical-position
filtering
@@ -876,13 +891,128 @@ async fn plan_and_search_pk_candidates_batch(
}
}
- let plan = PkVectorScan::new(table, field_id, index_type.clone(),
filter.cloned())
- .plan()
- .await?;
+ Ok(PkVectorSearchParams {
+ metric,
+ concurrency,
+ index_type,
+ field_id,
+ vector_field,
+ skip_exact_fallback,
+ refine_factor,
+ indexed_limit,
+ })
+}
+
+/// Search an already-resolved plan across every query and return each query's
raw
+/// indexed and exact candidate lists, before any rerank or merge.
+///
+/// Plan-dependent concurrency — the vindex segment count, batch-index
parallelism
+/// and the range-read bound — is derived here from the plan that is actually
being
+/// searched, so a narrowed plan can never be searched under limits computed
for a
+/// wider one.
+/// Combine the two per-split row allow-lists a search can be handed: the
physical
+/// rows an engine-supplied plan restricts each file to, and the positions a
residual
+/// data predicate leaves behind.
+///
+/// Both sides list what is permitted, and both read a file's absence as "no
rows
+/// allowed", so combining them intersects files as well as positions. Either
side
+/// alone passes through unchanged; neither side means no positional
restriction.
+///
+/// This is where the plan's ranges become positions: the search kernel tests
+/// membership, while a read is limited by the ranges themselves. When the
residual
+/// was evaluated over those same ranges the intersection cannot remove
anything, and
+/// is kept as the invariant that says so.
+fn intersect_row_allow_lists(
+ physical: Option<&[HashMap<String, Vec<RowRange>>]>,
+ residual: Option<Vec<HashMap<String, RoaringTreemap>>>,
+ split_count: usize,
+) -> crate::Result<Option<Vec<HashMap<String, RoaringTreemap>>>> {
+ if let Some(maps) = physical {
+ if maps.len() != split_count {
+ return Err(crate::Error::DataInvalid {
+ message: format!(
+ "plan carries {} physical row allow-lists for
{split_count} splits",
+ maps.len()
+ ),
+ source: None,
+ });
+ }
+ }
+ match (physical, residual) {
+ (None, residual) => Ok(residual),
+ (Some(physical), None) => Ok(Some(
+ physical
+ .iter()
+ .map(|per_file| {
+ per_file
+ .iter()
+ .map(|(file, ranges)| Ok((file.clone(),
positions_in_ranges(ranges)?)))
+ .collect::<crate::Result<HashMap<String,
RoaringTreemap>>>()
+ })
+ .collect::<crate::Result<Vec<_>>>()?,
+ )),
+ (Some(physical), Some(residual)) => {
+ if residual.len() != split_count {
+ return Err(crate::Error::DataInvalid {
+ message: format!(
+ "residual carries {} row allow-lists for {split_count}
splits",
+ residual.len()
+ ),
+ source: None,
+ });
+ }
+ Ok(Some(
+ physical
+ .iter()
+ .zip(residual)
+ .map(|(physical, residual)| {
+ physical
+ .iter()
+ .filter(|(file, _)|
residual.contains_key(file.as_str()))
+ .map(|(file, ranges)| {
+ let allowed = positions_in_ranges(ranges)?;
+ let kept = &residual[file.as_str()];
+ Ok((file.clone(), allowed & kept))
+ })
+ .collect::<crate::Result<HashMap<String,
RoaringTreemap>>>()
+ })
+ .collect::<crate::Result<Vec<_>>>()?,
+ ))
+ }
+ }
+}
+
+#[allow(clippy::too_many_arguments)]
+async fn search_pk_raw_candidates_batch_with_plan(
+ table: &Table,
+ query_options: &HashMap<String, String>,
+ filter: Option<&Predicate>,
+ core: &CoreOptions<'_>,
+ pk_col: &str,
+ queries: &[&[f32]],
+ limit: usize,
+ plan: &PkVectorScanPlan,
+ params: &PkVectorSearchParams,
+) -> crate::Result<Vec<OrchestratorSearchResult>> {
+ // An empty plan has nothing to search. Returned before the backend is
resolved
+ // so a table with no searchable data never errors on an unrecognized
index type.
if plan.splits.is_empty() {
- return Ok((vec![Vec::new(); queries.len()], plan, metric));
+ return Ok(queries
+ .iter()
+ .map(|_| OrchestratorSearchResult {
+ indexed: Vec::new(),
+ exact: Vec::new(),
+ })
+ .collect());
}
+ let metric = params.metric;
+ let concurrency = params.concurrency;
+ let index_type = params.index_type.clone();
+ let vector_field = params.vector_field.clone();
+ let skip_exact_fallback = params.skip_exact_fallback;
+ let indexed_limit = params.indexed_limit;
+
// Resolve the vector index backend from the single configured index type.
// Java enforces one index type per PK table and Rust filters segments to
it,
// so one backend serves every segment. Computed after the empty-plan
return so
@@ -1073,13 +1203,21 @@ async fn plan_and_search_pk_candidates_batch(
Vec::new(),
);
let mut per_split = Vec::with_capacity(plan.splits.len());
- for split in &plan.splits {
+ for (index, split) in plan.splits.iter().enumerate() {
+ // The plan's selection for this split, so the residual is
+ // evaluated over the rows an engine-supplied split allows
rather
+ // than over the whole file.
+ let allowed_rows = plan
+ .physical_row_ranges_by_split
+ .as_ref()
+ .and_then(|per_split| per_split.get(index));
per_split.push(
residual_positions_by_file(
&residual_reader,
&split.data_split,
&split.active_files,
&file_predicates,
+ allowed_rows,
)
.await?,
);
@@ -1089,6 +1227,15 @@ async fn plan_and_search_pk_candidates_batch(
}
None => None,
};
+ // Fold the plan's own positional restriction into the same allow-list. A
plan
+ // built from engine-supplied bucket splits carries the physical positions
each
+ // file is limited to; a plan read from the index manifest carries none.
Both
+ // sides list what is permitted, so combining them is an intersection.
+ let residual_by_split = intersect_row_allow_lists(
+ plan.physical_row_ranges_by_split.as_deref(),
+ residual_by_split,
+ plan.splits.len(),
+ )?;
// Build the exact-fallback search on demand: the kernel calls this only
for a
// file it actually searches (uncovered by ANN, residual-allowed, and only
when
@@ -1098,8 +1245,12 @@ async fn plan_and_search_pk_candidates_batch(
// per-query bounded heaps (all queries share one stream).
let reader_for_factory = reader.clone();
let vector_field_for_factory = vector_field.clone();
+ // The plan's own per-file selection, so an exact fallback reads only the
rows an
+ // engine-supplied split allows. `is_excluded` still rejects on top of it,
but it
+ // cannot un-read a row.
+ let physical_for_factory = plan.physical_row_ranges_by_split.clone();
let factory = as_split_exact_file_search(
- move |_split_index: usize,
+ move |split_index: usize,
split: &PkVectorSearchSplit,
file: &BucketActiveFile,
queries: &[&[f32]],
@@ -1115,11 +1266,24 @@ async fn plan_and_search_pk_candidates_batch(
row_count: file.row_count,
};
let owned_queries: Vec<Vec<f32>> = queries.iter().map(|q|
q.to_vec()).collect();
+ let allowed_rows =
physical_for_factory.as_ref().and_then(|per_split| {
+ per_split
+ .get(split_index)
+ .and_then(|per_file| per_file.get(&active.file_name))
+ .cloned()
+ });
Box::pin(async move {
let factory = DataFilePkVectorReaderFactory::new(reader,
data_split, vector_field)?;
let query_refs: Vec<&[f32]> = owned_queries.iter().map(|q|
q.as_slice()).collect();
factory
- .search_file(&active, &query_refs, metric, exact_limit,
is_excluded)
+ .search_file(
+ &active,
+ &query_refs,
+ metric,
+ exact_limit,
+ is_excluded,
+ allowed_rows.as_deref(),
+ )
.await
})
},
@@ -1150,6 +1314,41 @@ async fn plan_and_search_pk_candidates_batch(
)
.await?;
+ Ok(searches)
+}
+
+/// Search an already-resolved plan and return one merged, best-first
candidate list
+/// per query: the raw layer above, followed by the optional exact rerank of
the
+/// approximate candidates and the merge with the exact-fallback candidates.
+#[allow(clippy::too_many_arguments)]
+async fn search_pk_candidates_batch_with_plan(
+ table: &Table,
+ query_options: &HashMap<String, String>,
+ filter: Option<&Predicate>,
+ core: &CoreOptions<'_>,
+ pk_col: &str,
+ queries: &[&[f32]],
+ limit: usize,
+ plan: &PkVectorScanPlan,
+ params: &PkVectorSearchParams,
+) -> crate::Result<Vec<Vec<PkVectorCandidate>>> {
+ let searches = search_pk_raw_candidates_batch_with_plan(
+ table,
+ query_options,
+ filter,
+ core,
+ pk_col,
+ queries,
+ limit,
+ plan,
+ params,
+ )
+ .await?;
+
+ let metric = params.metric;
+ let refine_factor = params.refine_factor;
+ let vector_field = params.vector_field.clone();
+
// Per query: exact rerank of the approximate candidates when a refine
factor is
// set (exact-fallback candidates are already exact and are not reranked),
then
// merge the (possibly reranked) indexed list with the exact list into one
@@ -1186,7 +1385,56 @@ async fn plan_and_search_pk_candidates_batch(
per_query_candidates.push(merge_candidates(indexed, search.exact,
limit));
}
- Ok((per_query_candidates, plan, metric))
+ Ok(per_query_candidates)
+}
+
+/// Plan the whole table and search it: resolve the query parameters, read the
index
+/// manifest into a plan, then search that plan. The plan and metric are
returned
+/// alongside the candidates because callers re-associate hits through the
plan.
+async fn plan_and_search_pk_candidates_batch(
+ table: &Table,
+ query_options: &HashMap<String, String>,
+ filter: Option<&Predicate>,
+ core: &CoreOptions<'_>,
+ pk_col: &str,
+ queries: &[&[f32]],
+ limit: usize,
+) -> crate::Result<(
+ Vec<Vec<PkVectorCandidate>>,
+ PkVectorScanPlan,
+ VectorSearchMetric,
+)> {
+ let params = resolve_pk_vector_search_params(
+ table,
+ query_options,
+ filter,
+ core,
+ pk_col,
+ queries,
+ limit,
+ )?;
+ let plan = PkVectorScan::new(
+ table,
+ params.field_id,
+ params.index_type.clone(),
+ filter.cloned(),
+ )
+ .plan()
+ .await?;
+ let metric = params.metric;
+ let candidates = search_pk_candidates_batch_with_plan(
+ table,
+ query_options,
+ filter,
+ core,
+ pk_col,
+ queries,
+ limit,
+ &plan,
+ ¶ms,
+ )
+ .await?;
+ Ok((candidates, plan, metric))
}
impl<'a> BatchVectorSearchBuilder<'a> {
@@ -2242,11 +2490,16 @@ fn is_vector_global_index_file(index_file:
&IndexFileMeta) -> bool {
/// row-collecting half of Java `PrimaryKeyVectorRead`'s `executeFilter`: the
/// predicate is NOT pushed down (a pushed filter would drop rows before their
/// position could be recovered). Instead `reader` projects only the residual
-/// columns and carries no pushdown predicate; every physical row is scanned in
-/// file order, the residual is evaluated here at the Arrow level, and each
-/// surviving row's file-local 0-based position is its running ordinal in the
scan.
-/// This needs no `_ROW_ID` and no `first_row_id` — real primary-key tables
never
-/// write one.
+/// columns and carries no pushdown predicate, the residual is evaluated here
at the
+/// Arrow level, and each surviving row's file-local 0-based position is
recovered
+/// from the selection the read was limited to. This needs no `_ROW_ID` and no
+/// `first_row_id` — real primary-key tables never write one.
+///
+/// `allowed_rows` is the plan's per-file physical selection, when it has one.
The
+/// residual is evaluated over exactly those rows: an engine-supplied bucket
split
+/// can restrict a huge file to a handful of ranges, and reading the whole
file only
+/// to discard everything outside them afterwards would defeat the split. With
no
+/// selection every physical row is scanned, as before.
///
/// Every *active* data file in the split gets an entry, possibly empty. The
/// bucket search treats an absent entry and an empty entry identically (the
file
@@ -2263,6 +2516,7 @@ async fn residual_positions_by_file(
split: &DataSplit,
active_files: &[BucketActiveFile],
residual: &FilePredicates,
+ allowed_rows: Option<&HashMap<String, Vec<RowRange>>>,
) -> crate::Result<HashMap<String, RoaringTreemap>> {
let scan_fields = reader.read_type().to_vec();
let active_names: HashSet<&str> = active_files.iter().map(|f|
f.file_name.as_str()).collect();
@@ -2273,16 +2527,48 @@ async fn residual_positions_by_file(
if !active_names.contains(file_meta.file_name.as_str()) {
continue;
}
+ let selection = match allowed_rows {
+ // A plan that lists nothing for a file permits nothing from it,
whether
+ // the list is empty or the file is absent: both sides of the
eventual
+ // intersection read absence that way. Registering it empty says
so and
+ // costs no read.
+ Some(by_file) => match by_file.get(&file_meta.file_name) {
+ Some(ranges) if !ranges.is_empty() => Some(ranges.clone()),
+ _ => {
+ out.entry(file_meta.file_name.clone()).or_default();
+ continue;
+ }
+ },
+ None => None,
+ };
let data_fields = reader.derive_data_fields(file_meta).await?;
- let mut stream =
- reader.read_single_file_stream(split, file_meta.clone(),
data_fields, None, None)?;
+ let mut stream = match selection.clone() {
+ Some(ranges) => reader.read_single_file_stream_local_ranges(
+ split,
+ file_meta.clone(),
+ data_fields,
+ None,
+ ranges,
+ )?,
+ None => {
+ reader.read_single_file_stream(split, file_meta.clone(),
data_fields, None, None)?
+ }
+ };
// Register the file up front so a file whose rows all fail the
residual
// still appears in the map (empty set).
let positions = out.entry(file_meta.file_name.clone()).or_default();
- // The scan has no row selection and no DV, so rows arrive in physical
file
- // order with no gaps: each row's file-local 0-based position is its
running
- // ordinal `base + row_index`.
- let mut base: u64 = 0;
+ // Rows arrive in ascending physical order, and the read emitted
exactly what
+ // was selected (no pushdown predicate, no deletion vector), so
walking the
+ // selection in step with the rows recovers each row's file-local
position.
+ let mut selected: Box<dyn Iterator<Item = u64> + Send> = match
&selection {
+ Some(ranges) => Box::new(
+ ranges
+ .clone()
+ .into_iter()
+ .flat_map(|range| (range.from() as u64)..=(range.to() as
u64)),
+ ),
+ None => Box::new(0..file_meta.row_count.max(0) as u64),
+ };
while let Some(batch) = stream.try_next().await? {
let num_rows = batch.num_rows();
let mask = evaluate_predicates_mask(
@@ -2291,24 +2577,34 @@ async fn residual_positions_by_file(
&residual.file_fields,
&scan_fields,
)?;
- match mask {
- Some(mask) => {
- for row_index in 0..num_rows {
- // NULL follows the same NULL -> false convention the
Arrow
- // filter kernel applies, so a null mask slot drops
the row.
- if mask.is_valid(row_index) && mask.value(row_index) {
- positions.insert(base + row_index as u64);
- }
- }
- }
- // No predicate contributed a mask (identity) -> keep every
row.
- None => {
- for row_index in 0..num_rows {
- positions.insert(base + row_index as u64);
- }
+ for row_index in 0..num_rows {
+ let position = selected.next().ok_or_else(||
crate::Error::DataInvalid {
+ message: format!(
+ "residual scan of '{}' emitted more rows than the
selection allows",
+ file_meta.file_name
+ ),
+ source: None,
+ })?;
+ let keep = match &mask {
+ // NULL follows the same NULL -> false convention the
Arrow filter
+ // kernel applies, so a null mask slot drops the row.
+ Some(mask) => mask.is_valid(row_index) &&
mask.value(row_index),
+ // No predicate contributed a mask (identity) -> keep
every row.
+ None => true,
+ };
+ if keep {
+ positions.insert(position);
}
}
- base += num_rows as u64;
+ }
+ if selected.next().is_some() {
+ return Err(crate::Error::DataInvalid {
+ message: format!(
+ "residual scan of '{}' emitted fewer rows than the
selection allows",
+ file_meta.file_name
+ ),
+ source: None,
+ });
}
}
Ok(out)
@@ -7771,19 +8067,74 @@ mod residual_positions_tests {
&[("part-0.mosaic", vec![1, 2, 3, 4, 5], 0)],
)
.await;
- let map = residual_positions_by_file(&reader, &split, &active,
&residual_id_gt(2))
+ let map = residual_positions_by_file(&reader, &split, &active,
&residual_id_gt(2), None)
.await
.unwrap();
assert_eq!(sorted(&map["part-0.mosaic"]), vec![2, 3, 4]);
}
+ #[tokio::test]
+ async fn test_residual_only_evaluates_the_rows_the_plan_allows() {
+ // ids [1,2,3,4,5]; the plan allows positions 3-4 only. `id > 2`
matches 2,3,4
+ // over the whole file, so a result of 3,4 is the plan's restriction
taking
+ // effect *before* evaluation: position 2 is never seen.
+ //
+ // This also cannot pass under a full read. The scan walks the
selection in
+ // step with the emitted rows, so a read that emitted all five would
run the
+ // selection dry and fail loudly rather than return a filtered answer.
+ let (reader, split, active) = build_reader_and_split(
+ "memory:/rpf_plan_ranges",
+ &[("part-0.mosaic", vec![1, 2, 3, 4, 5], 0)],
+ )
+ .await;
+ let allowed = HashMap::from([("part-0.mosaic".to_string(),
vec![RowRange::new(3, 4)])]);
+ let map = residual_positions_by_file(
+ &reader,
+ &split,
+ &active,
+ &residual_id_gt(2),
+ Some(&allowed),
+ )
+ .await
+ .unwrap();
+ assert_eq!(sorted(&map["part-0.mosaic"]), vec![3, 4]);
+ }
+
+ #[tokio::test]
+ async fn test_residual_does_not_read_a_file_the_plan_excludes() {
+ // A file the plan lists no rows for is registered empty and never
opened. The
+ // empty entry is what tells the search the file contributes nothing;
an
+ // absent one would mean the same, but then the map would not cover
the split.
+ let (reader, split, active) = build_reader_and_split(
+ "memory:/rpf_plan_excludes",
+ &[("part-0.mosaic", vec![1, 2, 3], 0)],
+ )
+ .await;
+ for allowed in [
+ HashMap::from([("part-0.mosaic".to_string(), Vec::new())]),
+ HashMap::new(),
+ ] {
+ let map = residual_positions_by_file(
+ &reader,
+ &split,
+ &active,
+ &residual_id_gt(0),
+ Some(&allowed),
+ )
+ .await
+ .unwrap();
+ assert!(map.contains_key("part-0.mosaic"));
+ assert!(sorted(&map["part-0.mosaic"]).is_empty());
+ }
+ }
+
#[tokio::test]
async fn test_residual_matches_none_yields_empty_entry() {
// id > 100 matches nothing; the file still gets a (present, empty)
entry.
let (reader, split, active) =
build_reader_and_split("memory:/rpf_none", &[("part-0.mosaic",
vec![1, 2, 3], 0)])
.await;
- let map = residual_positions_by_file(&reader, &split, &active,
&residual_id_gt(100))
+ let map = residual_positions_by_file(&reader, &split, &active,
&residual_id_gt(100), None)
.await
.unwrap();
assert!(map.contains_key("part-0.mosaic"));
@@ -7794,7 +8145,7 @@ mod residual_positions_tests {
async fn test_residual_matches_all_yields_full_set() {
let (reader, split, active) =
build_reader_and_split("memory:/rpf_all", &[("part-0.mosaic",
vec![1, 2, 3], 0)]).await;
- let map = residual_positions_by_file(&reader, &split, &active,
&residual_id_gt(0))
+ let map = residual_positions_by_file(&reader, &split, &active,
&residual_id_gt(0), None)
.await
.unwrap();
assert_eq!(sorted(&map["part-0.mosaic"]), vec![0, 1, 2]);
@@ -7812,7 +8163,7 @@ mod residual_positions_tests {
],
)
.await;
- let map = residual_positions_by_file(&reader, &split, &active,
&residual_id_gt(3))
+ let map = residual_positions_by_file(&reader, &split, &active,
&residual_id_gt(3), None)
.await
.unwrap();
assert_eq!(sorted(&map["part-0.mosaic"]), vec![3, 4]);
@@ -7855,7 +8206,7 @@ mod residual_positions_tests {
.with_data_files(metas)
.build()
.unwrap();
- let map = residual_positions_by_file(&reader, &split, &active,
&residual_id_gt(2))
+ let map = residual_positions_by_file(&reader, &split, &active,
&residual_id_gt(2), None)
.await
.unwrap();
assert_eq!(sorted(&map["part-0.mosaic"]), vec![2, 3, 4]);
@@ -7871,7 +8222,7 @@ mod residual_positions_tests {
// recovered from each row's ordinal in the scan, so the residual still
// works: ids [1,2,3] with id > 0 -> all match -> local positions
[0,1,2].
let (reader, split, active) =
build_reader_and_split_no_first_row_id().await;
- let map = residual_positions_by_file(&reader, &split, &active,
&residual_id_gt(0))
+ let map = residual_positions_by_file(&reader, &split, &active,
&residual_id_gt(0), None)
.await
.expect("missing first_row_id must not fail the residual read");
assert_eq!(sorted(&map["part-0.mosaic"]), vec![0, 1, 2]);
@@ -7914,4 +8265,83 @@ mod residual_positions_tests {
}];
(reader, split, active)
}
+
+ // ---- combining the plan's positional restriction with the residual ----
+
+ fn allow_list(entries: &[(&str, &[u64])]) -> HashMap<String,
RoaringTreemap> {
+ entries
+ .iter()
+ .map(|(file, positions)| ((*file).to_string(),
positions.iter().copied().collect()))
+ .collect()
+ }
+
+ /// The plan side carries ranges, so its fixtures are built from the
positions
+ /// each file allows and coalesced the way the planner normalizes them.
+ fn range_allow_list(entries: &[(&str, &[u64])]) -> HashMap<String,
Vec<RowRange>> {
+ entries
+ .iter()
+ .map(|(file, positions)| {
+ let ranges = positions
+ .iter()
+ .map(|p| RowRange::new(*p as i64, *p as i64))
+ .collect();
+ ((*file).to_string(), merge_row_ranges(ranges))
+ })
+ .collect()
+ }
+
+ fn listed(map: &HashMap<String, RoaringTreemap>, file: &str) -> Vec<u64> {
+ map.get(file)
+ .map(|positions| positions.iter().collect())
+ .unwrap_or_default()
+ }
+
+ #[test]
+ fn no_restriction_on_either_side_stays_unrestricted() {
+ assert!(intersect_row_allow_lists(None, None, 1).unwrap().is_none());
+ }
+
+ #[test]
+ fn one_side_alone_passes_through() {
+ let physical = vec![range_allow_list(&[("d0", &[1, 2])])];
+ let only_physical = intersect_row_allow_lists(Some(&physical), None, 1)
+ .unwrap()
+ .expect("a plan restriction survives on its own");
+ assert_eq!(listed(&only_physical[0], "d0"), vec![1, 2]);
+
+ let residual = vec![allow_list(&[("d0", &[3])])];
+ let only_residual = intersect_row_allow_lists(None, Some(residual), 1)
+ .unwrap()
+ .expect("a residual survives on its own");
+ assert_eq!(listed(&only_residual[0], "d0"), vec![3]);
+ }
+
+ #[test]
+ fn both_sides_intersect_and_a_file_either_omits_is_dropped() {
+ // `d0`: both list positions, so only the shared ones survive. `d1`:
the
+ // residual kept nothing there, and its absence means "no rows", so
the file
+ // must not come back unrestricted from the plan side.
+ let physical = vec![range_allow_list(&[("d0", &[1, 2, 3]), ("d1", &[0,
1])])];
+ let residual = vec![allow_list(&[("d0", &[2, 3, 4])])];
+ let combined = intersect_row_allow_lists(Some(&physical),
Some(residual), 1)
+ .unwrap()
+ .expect("both sides restrict");
+ assert_eq!(listed(&combined[0], "d0"), vec![2, 3]);
+ assert!(!combined[0].contains_key("d1"));
+ }
+
+ #[test]
+ fn rejects_allow_lists_that_do_not_cover_every_split() {
+ let physical = vec![range_allow_list(&[("d0", &[1])])];
+ let error = intersect_row_allow_lists(Some(&physical), None, 2)
+ .map(|_| ())
+ .expect_err("an allow-list per split is what makes the index
meaningful");
+ assert!(error.to_string().contains("for 2 splits"), "{error}");
+
+ let residual = vec![allow_list(&[("d0", &[1])])];
+ let error = intersect_row_allow_lists(Some(&physical), Some(residual),
2)
+ .map(|_| ())
+ .expect_err("the residual must cover every split too");
+ assert!(error.to_string().contains("for 2 splits"), "{error}");
+ }
}
diff --git a/crates/paimon/src/vindex/pkvector/ann.rs
b/crates/paimon/src/vindex/pkvector/ann.rs
index 76cf6aa4..e2be9f87 100644
--- a/crates/paimon/src/vindex/pkvector/ann.rs
+++ b/crates/paimon/src/vindex/pkvector/ann.rs
@@ -89,18 +89,30 @@ pub(crate) fn build_live_row_ids(
// file_offset). A missing/empty entry allows no rows.
Some(ranges) => {
if let Some(allowed) = ranges.get(source_file.file_name())
{
- for position in allowed.iter() {
- if position >= row_count {
- return Err(data_invalid(format!(
- "residual position {position} is out of
range for source file {} ({} rows)",
- source_file.file_name(),
- row_count
- )));
+ // A producer that restricts only some files leaves
the rest
+ // unrestricted, and an adapter has to spell that out
as an
+ // explicit whole-file allow-list. Insert it as one
range
+ // rather than walking every position, which would
cost one
+ // insert per row of the file. `len` plus a maximum of
+ // `row_count - 1` can only describe the full set, and
it
+ // subsumes the per-position bound check below.
+ if allowed.len() == row_count && allowed.max() ==
Some(row_count - 1) {
+ live.insert_range(file_offset..end);
+ } else {
+ for position in allowed.iter() {
+ if position >= row_count {
+ return Err(data_invalid(format!(
+ "residual 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 residual position
overflows u64")
+ })?;
+ live.insert(global);
}
- let global =
file_offset.checked_add(position).ok_or_else(|| {
- data_invalid("vector residual position
overflows u64")
- })?;
- live.insert(global);
}
}
}
@@ -841,6 +853,44 @@ mod tests {
assert_eq!(live.iter().collect::<Vec<u64>>(), vec![0]);
}
+ #[test]
+ fn test_whole_file_allow_list_matches_having_no_residual_at_all() {
+ // An adapter spells "unrestricted" out as an explicit whole-file
allow-list.
+ // That has to land on the same live set the no-residual path
produces, since
+ // it is the same statement said two ways.
+ let files = vec![
+ PkVectorSourceFile::new("f0".into(), 3).unwrap(),
+ PkVectorSourceFile::new("f1".into(), 2).unwrap(),
+ ];
+ let active = active_set(&["f0", "f1"]);
+ let mut residual = HashMap::new();
+ residual.insert("f0".to_string(), treemap(&[0, 1, 2]));
+ residual.insert("f1".to_string(), treemap(&[0, 1]));
+
+ let spelled_out = build_live_row_ids(&files, &active, &HashMap::new(),
Some(&residual))
+ .unwrap()
+ .unwrap();
+ assert_eq!(
+ spelled_out.iter().collect::<Vec<u64>>(),
+ vec![0, 1, 2, 3, 4]
+ );
+ }
+
+ #[test]
+ fn test_whole_file_allow_list_still_applies_the_deletion_vector() {
+ // The whole-file shortcut must not skip deletion vectors: f0 allows
every
+ // row, but position 1 is deleted and has to stay out.
+ let files = vec![PkVectorSourceFile::new("f0".into(), 3).unwrap()];
+ let mut dvs = HashMap::new();
+ dvs.insert("f0".to_string(), dv(&[1]));
+ let mut residual = HashMap::new();
+ residual.insert("f0".to_string(), treemap(&[0, 1, 2]));
+ let live = build_live_row_ids(&files, &active_set(&["f0"]), &dvs,
Some(&residual))
+ .unwrap()
+ .unwrap();
+ assert_eq!(live.iter().collect::<Vec<u64>>(), vec![0, 2]);
+ }
+
#[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}.